feat: value-gated direct rules, per-value cache keys, NOT negation; fix value-object checks
DirectRule and the direct-check fast path now enforce expectedValue from a DSL literal (balance(user, 5)) or a per-check value-object override, closing a silent over-grant where any edge of a value-carrying fact matched. Rule and decision caches append :v<value> so different amounts never share a key. NOT negation is applied after union/intersection/exclusion evaluation (the only negation site was dead code in evaluateLogical), so NOT x now returns 1 - p instead of the raw possibility.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@arbiter/core",
|
"name": "@arbiter/core",
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
|
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -172,7 +172,30 @@ export class AuthorizationChecker {
|
|||||||
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
|
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
|
||||||
reason: 'threshold_not_met'
|
reason: 'threshold_not_met'
|
||||||
};
|
};
|
||||||
} else {
|
} else if (config?.expectedValue !== undefined && directRel.value !== config.expectedValue) {
|
||||||
|
// Value gate (mirrors DirectRule): a config carrying expectedValue
|
||||||
|
// (from a DSL literal like balance(user, 5)) only grants when the
|
||||||
|
// edge carries exactly that value.
|
||||||
|
result = {
|
||||||
|
possibility: 0,
|
||||||
|
reliability: 0,
|
||||||
|
...(includeMeta && {
|
||||||
|
meta: { reason: 'value_mismatch', expectedValue: config.expectedValue, actualValue: directRel.value }
|
||||||
|
}),
|
||||||
|
reason: 'value_mismatch'
|
||||||
|
};
|
||||||
|
} else if (options.expectedValue !== undefined && directRel.value !== options.expectedValue) {
|
||||||
|
// Per-check value-object override (check(user, can_withdraw, 5)):
|
||||||
|
// the value arrives on the check options, not the config.
|
||||||
|
result = {
|
||||||
|
possibility: 0,
|
||||||
|
reliability: 0,
|
||||||
|
...(includeMeta && {
|
||||||
|
meta: { reason: 'value_mismatch', expectedValue: options.expectedValue, actualValue: directRel.value }
|
||||||
|
}),
|
||||||
|
reason: 'value_mismatch'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
result = {
|
result = {
|
||||||
possibility: directRel.possibility,
|
possibility: directRel.possibility,
|
||||||
validity: includeMeta
|
validity: includeMeta
|
||||||
@@ -308,7 +331,8 @@ export class AuthorizationChecker {
|
|||||||
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
|
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
|
||||||
!binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
|
!binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
|
||||||
const ruleCacheKey = canCacheRuleResult
|
const ruleCacheKey = canCacheRuleResult
|
||||||
? this._getRuleResultCacheKey(userId, relation, objectId)
|
? this._getRuleResultCacheKey(userId, relation, objectId) +
|
||||||
|
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
|
||||||
: null;
|
: null;
|
||||||
if (canCacheRuleResult) {
|
if (canCacheRuleResult) {
|
||||||
const cached = this.decisionCache.getRule(ruleCacheKey);
|
const cached = this.decisionCache.getRule(ruleCacheKey);
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export class RuleEvaluator {
|
|||||||
!binary && !options.partialGraphContext && !includeMeta && !temporalPinned &&
|
!binary && !options.partialGraphContext && !includeMeta && !temporalPinned &&
|
||||||
options.cacheRuleResult !== false;
|
options.cacheRuleResult !== false;
|
||||||
const ruleCacheKey = canCacheRuleResult
|
const ruleCacheKey = canCacheRuleResult
|
||||||
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule)
|
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) +
|
||||||
|
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (canCacheRuleResult) {
|
if (canCacheRuleResult) {
|
||||||
@@ -95,17 +96,17 @@ export class RuleEvaluator {
|
|||||||
|
|
||||||
if (rule.union) {
|
if (rule.union) {
|
||||||
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rule.intersection) {
|
if (rule.intersection) {
|
||||||
const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rule.exclusion) {
|
if (rule.exclusion) {
|
||||||
const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defeasible configs ({ type: 'defeasible', when/unless/never/always })
|
// Defeasible configs ({ type: 'defeasible', when/unless/never/always })
|
||||||
@@ -191,6 +192,27 @@ export class RuleEvaluator {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the NOT operator's negation to a logical rule result. The DSL emits
|
||||||
|
* `NOT x` as { type:'logical', intersection:{ rules:[x], negate:true } };
|
||||||
|
* the individual union/intersection/exclusion evaluators don't honor the
|
||||||
|
* flag, so it is applied here after evaluation (possibility -> 1 - p).
|
||||||
|
*/
|
||||||
|
_applyNegate(result, rule) {
|
||||||
|
const negate = Boolean(
|
||||||
|
(rule.union && rule.union.negate) ||
|
||||||
|
(rule.intersection && rule.intersection.negate) ||
|
||||||
|
(rule.exclusion && rule.exclusion.negate)
|
||||||
|
);
|
||||||
|
if (!negate || !result || typeof result.possibility !== 'number') return result;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
possibility: Math.max(0, 1 - result.possibility),
|
||||||
|
...(result.meta ? { meta: { ...result.meta, negated: true } } : {}),
|
||||||
|
reason: 'negated'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_getComparatorCacheSignature(rule) {
|
_getComparatorCacheSignature(rule) {
|
||||||
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
|
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
|
||||||
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
|
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ export class DirectRule extends BaseRule {
|
|||||||
})
|
})
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Value gate: a rule carrying expectedValue (from a DSL literal like
|
||||||
|
// `balance(user, 5)`, or a per-check value-object override like
|
||||||
|
// `check(user, can_withdraw, 5)`) only grants when the matched edge carries
|
||||||
|
// exactly that value. Without it a value-carrying fact matches ANY edge of
|
||||||
|
// the relation, silently over-granting past the declared amount.
|
||||||
|
const expectedValue = rule.expectedValue !== undefined ? rule.expectedValue : options.expectedValue;
|
||||||
|
if (expectedValue !== undefined && directRel.value !== expectedValue) {
|
||||||
|
return this._createStandardResult({
|
||||||
|
possibility: 0,
|
||||||
|
...(includeMeta && {
|
||||||
|
meta: {
|
||||||
|
ruleType: 'direct',
|
||||||
|
reason: 'value_mismatch',
|
||||||
|
expectedValue,
|
||||||
|
actualValue: directRel.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
const relationStrength = directRel.possibility;
|
const relationStrength = directRel.possibility;
|
||||||
const _source = directRel.source || 'persistent';
|
const _source = directRel.source || 'persistent';
|
||||||
|
|||||||
Reference in New Issue
Block a user