Files
core/src/authorization/RuleEvaluator.js
T

451 lines
19 KiB
JavaScript
Raw Normal View History

import { DirectRule } from './rules/DirectRule.js';
import { ComputedRule } from './rules/ComputedRule.js';
import { ParentRule } from './rules/ParentRule.js';
import { TupleToUsersetRule } from './rules/TupleToUsersetRule.js';
import { MultiHopRule } from './rules/MultiHopRule.js';
import { LogicalOperators } from './rules/LogicalOperators.js';
import { RelationalComparatorRouter } from './rules/RelationalComparatorRouter.js';
import { ChainRule } from './rules/ChainRule.js';
import { ChallengeRule } from './rules/ChallengeRule.js';
import { ValueContext } from './ValueContext.js';
import { CompiledEvaluator } from './CompiledEvaluator.js';
export class RuleEvaluator {
constructor(arbiter) {
this.arbiter = arbiter;
this.ruleHandlers = {
direct: new DirectRule(arbiter),
computed: new ComputedRule(arbiter),
parent: new ParentRule(arbiter),
tuple_to_userset: new TupleToUsersetRule(arbiter),
multi_hop: new MultiHopRule(arbiter),
relational_comparator: new RelationalComparatorRouter(arbiter, this),
chain: new ChainRule(arbiter),
challenge: new ChallengeRule(arbiter)
};
this.logicalOperators = new LogicalOperators(arbiter, this);
this.compiledEvaluator = new CompiledEvaluator(arbiter, this, this.logicalOperators);
this._valueRequirementCache = new Map();
}
evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
const { binary = false, valueContext = null, includeMeta = true } = options;
// Convert string keys to numeric IDs if needed
const numericUserId = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId;
const numericObjectId = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId;
const needsValueContext = valueContext !== null && valueContext !== undefined
? true
: (options.collectValues === true || this._ruleRequiresValues(rule, new Set()));
const finalValueContext = needsValueContext ? (valueContext || new ValueContext(this.arbiter, options)) : null;
const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext);
const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta };
const canCacheRuleResult = !!this.arbiter.ruleResultCache && currentRelation &&
!binary && !options.partialGraphContext && !includeMeta &&
options.cacheRuleResult !== false;
const ruleCacheKey = canCacheRuleResult
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule)
: null;
if (canCacheRuleResult) {
const cached = this.arbiter.ruleResultCache.get(ruleCacheKey);
if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
this.arbiter.ruleResultCacheStats.hits++;
if (collectValues && finalValueContext && cached.result?.collectedValues?.length) {
const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule');
finalValueContext.addCollectedValues(cached.result.collectedValues, ruleType, rule);
}
return cached.result;
}
this.arbiter.ruleResultCacheStats.misses++;
}
// BINARY MODE: Fast evaluation with early termination
if (binary) {
return this._evaluateRuleBinary(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
}
if (rule && rule._compiled && options.useCompiled !== false && !(rule._compileErrors && rule._compileErrors.length)) {
const result = this.compiledEvaluator.evaluate(rule._compiled, userId, userKey, objectId, objectKey, visited, currentRelation, enhancedOptions);
if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) {
const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule');
finalValueContext.addCollectedValues(result.collectedValues, ruleType, rule);
}
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
if (rule.union) {
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
if (rule.intersection) {
const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
if (rule.exclusion) {
const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
// For other rule types, evaluate normally first
const handler = this.ruleHandlers[rule.type];
if (!handler) {
return {
possibility_allow: 0,
possibility_deny: 0,
...(collectValues && { collectedValues: [] }),
...(includeMeta && { meta_allow: null, meta_deny: null }),
reason: 'unknown_rule_type'
};
}
const result = handler.evaluate(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
// Add collected values to value context
if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) {
finalValueContext.addCollectedValues(result.collectedValues, rule.type, rule);
}
// Return original result if inference didn't help or values weren't sufficient
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
_getRuleResultCacheKey(userId, relation, objectId, rule) {
const base = this.arbiter.keyManager.createCompositeKey(userId, relation, objectId);
let suffix = 'rule';
if (rule) {
if (rule.union || rule.intersection || rule.exclusion) {
suffix = 'logical';
} else if (rule.type === 'direct') {
suffix = `direct:${rule.relation || 'unknown'}`;
} else if (rule.type === 'tuple_to_userset') {
suffix = `tupleset:${rule.tuplesetRelation || 'unknown'}:${rule.computedRelation || 'unknown'}`;
} else if (rule.type === 'chain' && Array.isArray(rule.steps)) {
const stepSig = rule.steps.map(step => {
if (typeof step === 'string') return step;
return `${step.relation || 'unknown'}:${step.direction || 'out'}`;
}).join('>');
suffix = `chain:${stepSig}`;
} else if (rule.type === 'multi_hop') {
suffix = `multihop:${rule.relation || 'unknown'}:${rule.maxDepth || 'auto'}`;
} else if (rule.type === 'parent') {
suffix = `parent:${rule.parentRelation || 'unknown'}`;
} else if (rule.type === 'relational_comparator') {
const signature = this._getComparatorCacheSignature(rule);
suffix = `comparator:${rule.comparator || 'unknown'}:${signature}`;
} else {
suffix = rule.type || 'rule';
}
}
return `${base}|${suffix}`;
}
_maybeCacheRuleResult(result, relation, cacheKey, enabled) {
if (!enabled || !cacheKey || !relation) return result;
this.arbiter.ruleResultCache.set(cacheKey, {
result,
timestamp: Date.now()
});
this.arbiter._cacheRuleResult(relation, cacheKey);
return result;
}
_getComparatorCacheSignature(rule) {
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
return `L(${leftSig})|R(${rightSig})`;
}
_getComparatorOperandSignature(operand) {
if (!operand || typeof operand !== 'object') return 'none';
const valueRelation = operand.valueRelation || operand.valueRelationResolved;
if (valueRelation) return `relation:${valueRelation}`;
const nestedRule = operand.rule || null;
if (!nestedRule || typeof nestedRule !== 'object') return 'value';
if (nestedRule.type === 'direct') {
const rel = nestedRule.relation || nestedRule.rel || nestedRule.label || nestedRule.name || 'unknown';
return `${nestedRule.reverse ? 'rev:' : ''}${rel}`;
}
if (nestedRule.type === 'logical') {
if (nestedRule._optimized?.kind === 'direct_list') {
const rels = nestedRule._optimized.direct
.map((direct) => `${direct.reverse ? 'rev:' : ''}${direct.relation}`)
.join(',');
return `direct_list:${nestedRule.op || 'logical'}:${rels}`;
}
if (Array.isArray(nestedRule.children)) {
const childKeys = nestedRule.children.map((child) => {
if (child?.type === 'direct') {
return `${child.reverse ? 'rev:' : ''}${child.relation}`;
}
return child?.type || 'unknown';
}).join(',');
return `logical:${nestedRule.op || 'logical'}:${childKeys}`;
}
return `logical:${nestedRule.op || 'logical'}`;
}
if (nestedRule.union) {
const rules = Array.isArray(nestedRule.union.rules) ? nestedRule.union.rules : (Array.isArray(nestedRule.union) ? nestedRule.union : []);
const rels = rules.map((child) => {
if (child?.type === 'direct') {
const rel = child.relation || child.rel || child.label || child.name || 'unknown';
return `${child.reverse ? 'rev:' : ''}${rel}`;
}
return child?.type || 'unknown';
}).join(',');
return `logical:union:${rels}`;
}
if (nestedRule.intersection) {
const rules = Array.isArray(nestedRule.intersection.rules) ? nestedRule.intersection.rules : (Array.isArray(nestedRule.intersection) ? nestedRule.intersection : []);
const rels = rules.map((child) => {
if (child?.type === 'direct') {
const rel = child.relation || child.rel || child.label || child.name || 'unknown';
return `${child.reverse ? 'rev:' : ''}${rel}`;
}
return child?.type || 'unknown';
}).join(',');
return `logical:intersection:${rels}`;
}
if (nestedRule.exclusion) {
const rules = Array.isArray(nestedRule.exclusion.rules) ? nestedRule.exclusion.rules : (Array.isArray(nestedRule.exclusion) ? nestedRule.exclusion : []);
const rels = rules.map((child) => {
if (child?.type === 'direct') {
const rel = child.relation || child.rel || child.label || child.name || 'unknown';
return `${child.reverse ? 'rev:' : ''}${rel}`;
}
return child?.type || 'unknown';
}).join(',');
return `logical:exclusion:${rels}`;
}
return nestedRule.type || 'value';
}
/**
* Binary mode rule evaluation: Ultra-fast with strict thresholds and value context
*/
_evaluateRuleBinary(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
const {
minAllowPossibility = 0.8,
maxDenyPossibility = 0.8,
fastPath = true,
valueContext = null,
includeMeta = true
} = options;
const needsValueContext = valueContext !== null && valueContext !== undefined
? true
: (options.collectValues === true || this._ruleRequiresValues(rule, new Set()));
const finalValueContext = needsValueContext ? (valueContext || new ValueContext(this.arbiter, options)) : null;
const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext);
const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta };
// Handle logical operators in binary mode
if (rule.union) {
return this.logicalOperators.evaluateUnion(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
});
}
if (rule.intersection) {
return this.logicalOperators.evaluateIntersection(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
});
}
if (rule.exclusion) {
return this.logicalOperators.evaluateExclusion(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
});
}
// Defeasible rules (when/unless/never/always/requires) have no entry in
// ruleHandlers — normal mode reaches them via the compiled evaluator,
// but binary mode short-circuits to this method, where a bare handler
// lookup returned unknown_rule_type (a hard binary deny for every
// defeasible config). Route them through the defeasible evaluator,
// which implements binary/threshold modes natively.
if (this.logicalOperators._isDefeasibleLogic(rule)) {
if (rule._compiled && !(rule._compileErrors && rule._compileErrors.length)) {
return this.compiledEvaluator.evaluate(rule._compiled, userId, userKey, objectId, objectKey, visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
});
}
return this.logicalOperators.evaluateDefeasible(userId, userKey, objectId, objectKey, wrapDefeasibleComponents(rule), visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
});
}
// Get rule handler
const handler = this.ruleHandlers[rule.type];
if (!handler) {
return {
possibility_allow: 0,
possibility_deny: 0,
...(collectValues && { collectedValues: [] }),
...(includeMeta && { meta_allow: null, meta_deny: null }),
reason: 'unknown_rule_type',
binary: true
};
}
// Evaluate rule with binary options
const result = handler.evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
});
// Add collected values to value context in binary mode too
// This is important if downstream rules in a logical operator need them
if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) {
finalValueContext.addCollectedValues(result.collectedValues, rule.type, rule);
}
// Binary mode means we only care about allow/deny based on thresholds
const allow = result.possibility_allow >= minAllowPossibility;
const deny = result.possibility_deny >= maxDenyPossibility;
return {
possibility_allow: allow ? 1 : 0,
possibility_deny: deny ? 1 : 0,
// Preserve the real continuous possibility so logical operators
// (union/intersection/exclusion) can aggregate child strengths —
// binarized 0/1 values alone corrupt max/min/product aggregation.
possibility: result.possibility ?? result.possibility_allow ?? (allow ? 1 : 0),
...(includeMeta && { meta_allow: allow ? (result.meta_allow || result.meta) : null, meta_deny: deny ? result.meta_deny : null }),
reason: result.reason || (allow ? 'binary_allow_threshold_met' : (deny ? 'binary_deny_threshold_met' : 'binary_threshold_not_met')),
binary: true,
collectedValues: result.collectedValues || [] // Preserve collected values
};
}
/**
* Get value context statistics for debugging
*/
getValueContextStats(options = {}) {
const { valueContext } = options;
return valueContext ? valueContext.getStats() : null;
}
_ruleRequiresValues(rule, visitedRelations) {
if (!rule || typeof rule !== 'object') return false;
if (rule.type === 'relational_comparator' || rule.qualitative === true) {
rule._needsValues = true;
return true;
}
if (rule.type === 'computed') {
const relationName = rule.relation;
if (relationName) {
if (visitedRelations.has(relationName)) return false;
visitedRelations.add(relationName);
if (this._valueRequirementCache.has(relationName)) {
return this._valueRequirementCache.get(relationName);
}
const config = this.arbiter.relationConfigs.get(relationName);
const requiresValues = this._ruleRequiresValues(config, visitedRelations);
this._valueRequirementCache.set(relationName, requiresValues);
if (config && typeof config === 'object') {
config._needsValues = requiresValues;
}
return requiresValues;
}
}
const traverse = (child) => this._ruleRequiresValues(child, visitedRelations);
if (Array.isArray(rule.union) && rule.union.some(traverse)) {
rule._needsValues = true;
return true;
}
if (rule.union && Array.isArray(rule.union.rules) && rule.union.rules.some(traverse)) {
rule._needsValues = true;
return true;
}
if (Array.isArray(rule.intersection) && rule.intersection.some(traverse)) {
rule._needsValues = true;
return true;
}
if (rule.intersection && Array.isArray(rule.intersection.rules) && rule.intersection.rules.some(traverse)) {
rule._needsValues = true;
return true;
}
if (Array.isArray(rule.exclusion) && rule.exclusion.some(traverse)) {
rule._needsValues = true;
return true;
}
if (rule.when && this._ruleRequiresValues(rule.when, visitedRelations)) {
rule._needsValues = true;
return true;
}
if (rule.unless && this._ruleRequiresValues(rule.unless, visitedRelations)) {
rule._needsValues = true;
return true;
}
if (rule.requires && this._ruleRequiresValues(rule.requires, visitedRelations)) {
rule._needsValues = true;
return true;
}
rule._needsValues = false;
return false;
}
}
/**
* Wrap raw defeasible components (single rules like
* `{ when: { type: 'direct', relation: 'x' } }`) into the object shape
* LogicalOperators.evaluateDefeasible expects
* (`when.intersection.rules[]`, `unless.union.rules[]`, `always.direct`).
* Logical (union/intersection/exclusion) components pass through.
*/
function wrapDefeasibleComponents(rule) {
const wrapSingle = (component) => ({ rules: [component] });
const wrapUnion = (component) => {
if (!component) return component;
if (Array.isArray(component)) return { rules: component };
if (component.union || component.intersection || component.exclusion) return component;
return wrapSingle(component);
};
const wrapIntersection = (component) => {
if (!component) return component;
if (Array.isArray(component)) return { rules: component };
if (component.union || component.intersection || component.exclusion) return component;
return wrapSingle(component);
};
return {
...rule,
when: rule.when ? wrapIntersection(rule.when) : rule.when,
unless: rule.unless ? wrapUnion(rule.unless) : rule.unless,
never: rule.never ? wrapUnion(rule.never) : rule.never,
requires: rule.requires ? wrapUnion(rule.requires) : rule.requires,
always: rule.always ? { direct: rule.always.direct || rule.always } : rule.always
};
}