889 lines
32 KiB
JavaScript
889 lines
32 KiB
JavaScript
|
|
import { Arbiter } from '../core/Arbiter.js';
|
||
|
|
import { RuleEvaluator } from './RuleEvaluator.js';
|
||
|
|
import { RuleCollector } from './RuleCollector.js';
|
||
|
|
import { ScratchBuffers } from './ScratchBuffers.js';
|
||
|
|
import { buildRemediation, extractRemediation, mergeRemediationOptions } from './remediation.js';
|
||
|
|
import { DecisionCache } from './DecisionCache.js';
|
||
|
|
|
||
|
|
export class AuthorizationChecker {
|
||
|
|
constructor(arbiter, options = {}) {
|
||
|
|
this.arbiter = arbiter;
|
||
|
|
this.ruleEvaluator = new RuleEvaluator(arbiter);
|
||
|
|
this.ruleCollector = new RuleCollector(arbiter);
|
||
|
|
// DecisionCache port — RF-03 closure. When not injected, default to
|
||
|
|
// an ArbiterDecisionCache that forwards to the arbiter's existing
|
||
|
|
// cache fields, preserving the behavior every test relies on.
|
||
|
|
this.decisionCache = options.decisionCache || new DecisionCache(arbiter);
|
||
|
|
}
|
||
|
|
|
||
|
|
check(userKey, relation, objectKey, options = {}) {
|
||
|
|
// Handle backward compatibility
|
||
|
|
if (options instanceof Set) {
|
||
|
|
options = { _visited: options, _currentRelation: arguments[4] };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!options.scratch) {
|
||
|
|
options.scratch = new ScratchBuffers();
|
||
|
|
}
|
||
|
|
|
||
|
|
const {
|
||
|
|
_visited = new Set(),
|
||
|
|
_currentRelation = null,
|
||
|
|
// Threshold-based early exit options
|
||
|
|
minAllowPossibility = null,
|
||
|
|
maxDenyPossibility = null,
|
||
|
|
fastPath = false,
|
||
|
|
// NEW: Binary mode for ultra-fast decisive authorization
|
||
|
|
binary = false
|
||
|
|
} = options;
|
||
|
|
const explain = options.explain === true;
|
||
|
|
const includeMeta = options.includeMeta === undefined ? explain : options.includeMeta;
|
||
|
|
const trackEvaluation = options.trackEvaluation === undefined ? (explain ? true : false) : options.trackEvaluation;
|
||
|
|
let collectValues = options.collectValues;
|
||
|
|
const hasPartialGraph = !!options.partialGraphContext;
|
||
|
|
|
||
|
|
// BINARY MODE: Ultra-fast decisive authorization
|
||
|
|
if (binary) {
|
||
|
|
return this._checkBinary(userKey, relation, objectKey, {
|
||
|
|
_visited,
|
||
|
|
_currentRelation,
|
||
|
|
minAllowPossibility: minAllowPossibility || 0.8, // Default strict threshold
|
||
|
|
maxDenyPossibility: maxDenyPossibility || 0.8,
|
||
|
|
includeMeta,
|
||
|
|
trackEvaluation
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const config = this.arbiter.relationConfigs.get(relation);
|
||
|
|
if (collectValues === undefined) {
|
||
|
|
collectValues = explain || config?._needsValues || false;
|
||
|
|
}
|
||
|
|
// CI-001 fix: the previous `!config._compiled` guard made the
|
||
|
|
// direct-check fast path dead. `setRelationConfig` compiles
|
||
|
|
// synchronously and sets `_compiled` immediately (ArbiterConfig.js),
|
||
|
|
// so the guard was always false in production and `_cacheDirectCheckResult`
|
||
|
|
// never fired. The fast-path decision is independent of compilation state.
|
||
|
|
//
|
||
|
|
// Narrowing: derived evidence rules normalize to type 'direct' but carry
|
||
|
|
// `dependsOn` (e.g. session_authenticated_action { userIsActive(user) }).
|
||
|
|
// Their semantics live in the compiled dependency evaluation, not in a raw
|
||
|
|
// direct lookup — the fast path must not bypass them, or gate checks deny
|
||
|
|
// with 'no_relation' where the full path derives possibility 1.0.
|
||
|
|
const hasDerivedDependencies = Array.isArray(config?.dependsOn) && config.dependsOn.length > 0;
|
||
|
|
const useFastPath = config && config.type === 'direct' && !config.union && !config.intersection && !config.exclusion && !hasDerivedDependencies;
|
||
|
|
const effectiveThreshold = minAllowPossibility ?? config?.minPossibility ?? null;
|
||
|
|
// A direct config may override the relation it checks (rule.relation).
|
||
|
|
// The fast path must honor that override or it diverges from the
|
||
|
|
// rule-evaluation path (e.g. can_read -> gateway_context_ref).
|
||
|
|
const effectiveRelation = (config && (config.relation || config.rel)) || relation;
|
||
|
|
if (useFastPath) {
|
||
|
|
// Check cache first using composite key (if caching is enabled)
|
||
|
|
let cachedResult = null;
|
||
|
|
let cacheHint = null;
|
||
|
|
if (!hasPartialGraph && this.decisionCache.directEnabled) {
|
||
|
|
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||
|
|
const [hitResult, status] = this.decisionCache.peekDirect(cacheKey);
|
||
|
|
cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null;
|
||
|
|
if (status === 'hit') {
|
||
|
|
cacheHint = { hit: true, result: hitResult };
|
||
|
|
if (!explain) {
|
||
|
|
return hitResult;
|
||
|
|
}
|
||
|
|
} else if (explain && status === 'expired') {
|
||
|
|
cacheHint = { hit: false, result: hitResult };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ultra-fast direct check
|
||
|
|
const userId = this.arbiter.resolveNodeId(userKey, options);
|
||
|
|
const objectId = this.arbiter.resolveNodeId(objectKey, options);
|
||
|
|
|
||
|
|
if (userId === undefined || objectId === undefined) {
|
||
|
|
const result = {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'missing_node' } }),
|
||
|
|
reason: 'missing_node'
|
||
|
|
};
|
||
|
|
|
||
|
|
// Cache the result (only when no partial graph — same guard as success path)
|
||
|
|
if (!explain && !hasPartialGraph) {
|
||
|
|
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (_visited.size) {
|
||
|
|
const useKeyedVisited = this._getVisitedMode(_visited);
|
||
|
|
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||
|
|
if (useKeyedVisited) {
|
||
|
|
if (_visited.has(visitKey)) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||
|
|
reason: 'cycle'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
for (const visited of _visited) {
|
||
|
|
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||
|
|
reason: 'cycle'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Direct index lookup
|
||
|
|
let directRel = null;
|
||
|
|
let partialRel = null;
|
||
|
|
if (hasPartialGraph) {
|
||
|
|
partialRel = options.partialGraphContext.getDirectRelation(userId, effectiveRelation, objectId);
|
||
|
|
}
|
||
|
|
directRel = this.arbiter.indices.getDirectRelation(userId, effectiveRelation, objectId) || partialRel;
|
||
|
|
|
||
|
|
let result;
|
||
|
|
if (directRel) {
|
||
|
|
// Check threshold-based early exit
|
||
|
|
if (effectiveThreshold !== null && directRel.possibility < effectiveThreshold) {
|
||
|
|
result = {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
|
||
|
|
reason: 'threshold_not_met'
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
result = {
|
||
|
|
possibility: directRel.possibility,
|
||
|
|
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
|
||
|
|
...(includeMeta && {
|
||
|
|
meta: {
|
||
|
|
allow: {
|
||
|
|
ruleType: 'direct',
|
||
|
|
reason: 'direct',
|
||
|
|
source: directRel.source || 'persistent',
|
||
|
|
layer_name: directRel.layer_name || null,
|
||
|
|
source_class: directRel.source_class || null,
|
||
|
|
reducer_applied: directRel.reducer_applied || null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
reason: 'direct_match'
|
||
|
|
};
|
||
|
|
|
||
|
|
// Collect values if present
|
||
|
|
if (collectValues && directRel.value !== undefined) {
|
||
|
|
result.collectedValues = [{
|
||
|
|
value: directRel.value,
|
||
|
|
source: 'direct_relation',
|
||
|
|
relation: relation,
|
||
|
|
userKey: userKey,
|
||
|
|
objectKey: objectKey
|
||
|
|
}];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Fast-path miss: attach remediation when the missing (effective)
|
||
|
|
// relation is declared as an injectable witness source — the caller
|
||
|
|
// needs to know which relation to satisfy.
|
||
|
|
const missingConfig = this.arbiter.relationConfigs.get(effectiveRelation);
|
||
|
|
let remediation = null;
|
||
|
|
if (missingConfig && missingConfig.injectable) {
|
||
|
|
remediation = buildRemediation(null, {
|
||
|
|
status: 'required',
|
||
|
|
additional_options: [{ relation: effectiveRelation, object: objectKey }]
|
||
|
|
});
|
||
|
|
}
|
||
|
|
result = {
|
||
|
|
possibility: 0,
|
||
|
|
reliability: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'no_relation' } }),
|
||
|
|
reason: 'no_relation',
|
||
|
|
...(remediation ? { remediation } : {})
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (explain && cacheHint) {
|
||
|
|
result.meta = result.meta || {};
|
||
|
|
result.meta.cache = cacheHint;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cache the result using composite key
|
||
|
|
if (!explain && !hasPartialGraph) {
|
||
|
|
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.arbiter.relationManager._ensureIndicesBuilt();
|
||
|
|
|
||
|
|
|
||
|
|
const userId = this.arbiter.resolveNodeId(userKey, options);
|
||
|
|
const objectId = this.arbiter.resolveNodeId(objectKey, options);
|
||
|
|
|
||
|
|
if (userId === undefined || objectId === undefined) {
|
||
|
|
const missingNode = userId === undefined ? userKey : objectKey;
|
||
|
|
const missingType = userId === undefined ? 'user' : 'object';
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && {
|
||
|
|
meta: {
|
||
|
|
reason: 'missing_node',
|
||
|
|
missingNode,
|
||
|
|
missingType
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
reason: 'missing_node'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!config) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'no_config' } }),
|
||
|
|
reason: 'no_config'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
|
||
|
|
!binary && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
|
||
|
|
const ruleCacheKey = canCacheRuleResult
|
||
|
|
? this._getRuleResultCacheKey(userId, relation, objectId)
|
||
|
|
: null;
|
||
|
|
if (canCacheRuleResult) {
|
||
|
|
const cached = this.decisionCache.getRule(ruleCacheKey);
|
||
|
|
if (cached) {
|
||
|
|
return cached;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const useKeyedVisited = this._getVisitedMode(_visited);
|
||
|
|
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||
|
|
if (useKeyedVisited) {
|
||
|
|
if (_visited.has(visitKey)) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||
|
|
reason: 'cycle'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
for (const visited of _visited) {
|
||
|
|
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||
|
|
reason: 'cycle'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
_visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey });
|
||
|
|
|
||
|
|
const shouldTrackEvaluation = trackEvaluation && includeMeta;
|
||
|
|
const evaluationErrors = shouldTrackEvaluation ? [] : null;
|
||
|
|
const evaluationPath = shouldTrackEvaluation ? {
|
||
|
|
userKey,
|
||
|
|
relation,
|
||
|
|
objectKey,
|
||
|
|
config,
|
||
|
|
rules: [],
|
||
|
|
visitedPath: Array.from(_visited),
|
||
|
|
errors: evaluationErrors
|
||
|
|
} : null;
|
||
|
|
|
||
|
|
const evalOptions = {
|
||
|
|
...options,
|
||
|
|
fastPath,
|
||
|
|
minAllowPossibility,
|
||
|
|
minPossibility: minAllowPossibility,
|
||
|
|
maxDenyPossibility,
|
||
|
|
trackEvaluation: shouldTrackEvaluation,
|
||
|
|
collectValues,
|
||
|
|
includeMeta
|
||
|
|
};
|
||
|
|
|
||
|
|
if (config.union || config.intersection || config.exclusion) {
|
||
|
|
const res = this.ruleEvaluator.evaluateRule(
|
||
|
|
userId,
|
||
|
|
userKey,
|
||
|
|
objectId,
|
||
|
|
objectKey,
|
||
|
|
config,
|
||
|
|
_visited,
|
||
|
|
relation,
|
||
|
|
evalOptions
|
||
|
|
);
|
||
|
|
|
||
|
|
if (evaluationPath) {
|
||
|
|
evaluationPath.type = 'logical_operator';
|
||
|
|
evaluationPath.operator = config.union ? 'union' : config.intersection ? 'intersection' : 'exclusion';
|
||
|
|
evaluationPath.result = res;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle both old format (possibility_allow) and new format (possibility)
|
||
|
|
const resPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility;
|
||
|
|
|
||
|
|
// Extract allow/deny from meta to avoid conflicts
|
||
|
|
const { allow: metaAllow, deny: metaDeny, ...restMeta } = res.meta || {};
|
||
|
|
|
||
|
|
const remediation = buildRemediation(extractRemediation(res));
|
||
|
|
const finalResult = {
|
||
|
|
possibility: resPossibility || 0,
|
||
|
|
reliability: res.reliability !== undefined ? res.reliability : 1.0,
|
||
|
|
...(includeMeta && {
|
||
|
|
meta: {
|
||
|
|
...restMeta, // Spread meta without allow/deny
|
||
|
|
allow: res.meta_allow || metaAllow, // Use meta_allow if available, otherwise meta.allow
|
||
|
|
deny: res.meta_deny || metaDeny,
|
||
|
|
...(evaluationPath && { evaluation: evaluationPath }),
|
||
|
|
earlyExit: res.meta?.earlyExit || false
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
...(remediation ? { remediation } : {}),
|
||
|
|
reason: res.reason || 'logical_operator_evaluation'
|
||
|
|
};
|
||
|
|
return this._maybeCacheRuleResult(finalResult, relation, ruleCacheKey, canCacheRuleResult);
|
||
|
|
}
|
||
|
|
|
||
|
|
const rules = this.ruleCollector.collectRules(config, null, relation);
|
||
|
|
|
||
|
|
if (evaluationPath) {
|
||
|
|
evaluationPath.type = 'rule_collection';
|
||
|
|
evaluationPath.collectedRules = rules.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
let maxAllow = 0;
|
||
|
|
let maxDeny = 0;
|
||
|
|
let bestAllowReliability = 0;
|
||
|
|
let bestDenyReliability = 0;
|
||
|
|
let bestAllow = null;
|
||
|
|
let bestDeny = null;
|
||
|
|
let reason = undefined;
|
||
|
|
let allRuleResults = shouldTrackEvaluation ? [] : null;
|
||
|
|
let allCollectedValues = collectValues ? [] : null; // Collect values from all evaluated rules
|
||
|
|
|
||
|
|
const remediationOptions = [];
|
||
|
|
let ruleIndex = 0;
|
||
|
|
for (const rule of rules) {
|
||
|
|
const res = this.ruleEvaluator.evaluateRule(userId, userKey, objectId, objectKey, rule, _visited, relation, evalOptions);
|
||
|
|
|
||
|
|
// Handle both old format (possibility_allow/deny) and new format (possibility)
|
||
|
|
const resAllowPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility;
|
||
|
|
const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0;
|
||
|
|
const resMeta = res.meta_allow || res.meta?.allow || null;
|
||
|
|
mergeRemediationOptions(remediationOptions, extractRemediation(res));
|
||
|
|
|
||
|
|
if (evaluationErrors && (res.error || res.reason === 'evaluation_error')) {
|
||
|
|
evaluationErrors.push({
|
||
|
|
ruleIndex,
|
||
|
|
level: rule.ruleType || null,
|
||
|
|
relation: rule.relation || relation,
|
||
|
|
message: res.details?.error || res.reason || 'evaluation_error',
|
||
|
|
stack: res.details?.stack || null
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Track each rule evaluation
|
||
|
|
if (shouldTrackEvaluation) {
|
||
|
|
const ruleEvaluation = {
|
||
|
|
rule: {
|
||
|
|
type: rule.type,
|
||
|
|
relation: rule.relation,
|
||
|
|
ruleType: rule.ruleType,
|
||
|
|
reverse: rule.reverse
|
||
|
|
},
|
||
|
|
result: {
|
||
|
|
possibility_allow: resAllowPossibility,
|
||
|
|
possibility_deny: resDenyPossibility,
|
||
|
|
reason: res.reason
|
||
|
|
},
|
||
|
|
meta: {
|
||
|
|
allow: resMeta,
|
||
|
|
deny: res.meta_deny,
|
||
|
|
full: res.meta || null
|
||
|
|
}
|
||
|
|
};
|
||
|
|
allRuleResults.push(ruleEvaluation);
|
||
|
|
}
|
||
|
|
|
||
|
|
ruleIndex += 1;
|
||
|
|
|
||
|
|
// Preserve specific reasons from rule evaluations
|
||
|
|
if (res.reason === 'cycle') reason = 'cycle';
|
||
|
|
if (res.reason === 'no_path') reason = 'no_path';
|
||
|
|
if (res.reason === 'no_similar_users') reason = 'no_similar_users';
|
||
|
|
if (res.reason === 'no_similar_authorized') reason = 'no_similar_authorized';
|
||
|
|
if (res.reason === 'no_similar_objects') reason = 'no_similar_objects';
|
||
|
|
if (res.reason === 'no_user_objects') reason = 'no_user_objects';
|
||
|
|
if (res.reason === 'no_target_embedding') reason = 'no_target_embedding';
|
||
|
|
if (res.reason === 'no_embedding') reason = 'no_embedding';
|
||
|
|
// Add chain rule reasons
|
||
|
|
if (res.reason === 'chain_path_found') reason = 'chain_path_found';
|
||
|
|
if (res.reason === 'no_chain_path_found') reason = 'no_chain_path_found';
|
||
|
|
if (res.reason === 'no_chain_steps_defined') reason = 'no_chain_steps_defined';
|
||
|
|
// Add parent rule reasons
|
||
|
|
if (res.reason === 'no_parent_relationship_found') reason = 'no_parent_relationship_found';
|
||
|
|
if (res.reason === 'no_parent_relationship_path_above_threshold') reason = 'no_parent_relationship_path_above_threshold';
|
||
|
|
// Add multi-hop rule reasons
|
||
|
|
if (res.reason === 'no_multihop_path_found') reason = 'no_multihop_path_found';
|
||
|
|
if (res.reason === 'multihop_path_found') reason = 'multihop_path_found';
|
||
|
|
// Add direct rule reasons
|
||
|
|
if (res.reason === 'direct_match') reason = 'direct_match';
|
||
|
|
if (res.reason === 'no_direct_match') reason = 'no_direct_match';
|
||
|
|
// Add tuple-to-userset reasons
|
||
|
|
if (res.reason === 'tuple_to_userset_match') reason = 'tuple_to_userset_match';
|
||
|
|
if (res.reason === 'no_tuple_to_userset_match') reason = 'no_tuple_to_userset_match';
|
||
|
|
// Add relational comparator reasons
|
||
|
|
if (res.reason === 'values_compared_comparison_true') reason = 'values_compared_comparison_true';
|
||
|
|
if (res.reason === 'values_compared_comparison_false') reason = 'values_compared_comparison_false';
|
||
|
|
if (res.reason === 'values_compared_comparison_insufficient') reason = 'values_compared_comparison_insufficient';
|
||
|
|
|
||
|
|
if (resAllowPossibility > maxAllow) {
|
||
|
|
maxAllow = resAllowPossibility;
|
||
|
|
bestAllow = resMeta;
|
||
|
|
bestAllowReliability = res.reliability !== undefined ? res.reliability : 1.0;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (resDenyPossibility > maxDeny) {
|
||
|
|
maxDeny = resDenyPossibility;
|
||
|
|
bestDeny = res.meta_deny;
|
||
|
|
bestDenyReliability = res.reliability !== undefined ? res.reliability : 1.0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fast path early exit checks
|
||
|
|
if (fastPath) {
|
||
|
|
let shouldExit = false;
|
||
|
|
let exitReason = null;
|
||
|
|
|
||
|
|
// Check allow threshold
|
||
|
|
if (minAllowPossibility !== null && maxAllow >= minAllowPossibility) {
|
||
|
|
shouldExit = true;
|
||
|
|
exitReason = 'allow_threshold_met';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check deny threshold
|
||
|
|
if (maxDenyPossibility !== null && maxDeny >= maxDenyPossibility) {
|
||
|
|
shouldExit = true;
|
||
|
|
exitReason = 'deny_threshold_met';
|
||
|
|
}
|
||
|
|
|
||
|
|
if (shouldExit) {
|
||
|
|
if (evaluationPath) {
|
||
|
|
evaluationPath.rules = allRuleResults;
|
||
|
|
evaluationPath.earlyExit = {
|
||
|
|
reason: exitReason,
|
||
|
|
threshold: exitReason === 'allow_threshold_met' ? minAllowPossibility : maxDenyPossibility,
|
||
|
|
actualValue: exitReason === 'allow_threshold_met' ? maxAllow : maxDeny,
|
||
|
|
rulesEvaluated: allRuleResults.length,
|
||
|
|
totalRules: rules.length
|
||
|
|
};
|
||
|
|
Arbiter.log('early exit triggered:', evaluationPath.earlyExit);
|
||
|
|
}
|
||
|
|
|
||
|
|
const finalResult = {
|
||
|
|
possibility: maxAllow,
|
||
|
|
reliability: maxAllow > 0 ? bestAllowReliability : 0,
|
||
|
|
...(includeMeta && {
|
||
|
|
meta: {
|
||
|
|
allow: bestAllow,
|
||
|
|
deny: bestDeny,
|
||
|
|
...(evaluationPath && { evaluation: evaluationPath }),
|
||
|
|
earlyExit: true,
|
||
|
|
maxDeny: maxDeny
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
reason: exitReason
|
||
|
|
};
|
||
|
|
return this._maybeCacheRuleResult(finalResult, relation, ruleCacheKey, canCacheRuleResult);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect values from all evaluated rules
|
||
|
|
if (collectValues && res.collectedValues && Array.isArray(res.collectedValues)) {
|
||
|
|
allCollectedValues.push(...res.collectedValues);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (evaluationPath) {
|
||
|
|
evaluationPath.rules = allRuleResults;
|
||
|
|
evaluationPath.finalResult = {
|
||
|
|
maxAllow,
|
||
|
|
maxDeny,
|
||
|
|
reason
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Determine final reason based on evaluation
|
||
|
|
let finalReason = reason;
|
||
|
|
if (maxAllow === 0 && maxDeny === 0) {
|
||
|
|
finalReason = reason || 'no_matching_rule';
|
||
|
|
} else if (maxAllow > 0 && maxDeny > 0) {
|
||
|
|
finalReason = 'conflicting_rules';
|
||
|
|
} else if (maxAllow > 0) {
|
||
|
|
finalReason = 'allow_rule_matched';
|
||
|
|
} else if (maxDeny > 0) {
|
||
|
|
finalReason = 'deny_rule_matched';
|
||
|
|
}
|
||
|
|
|
||
|
|
const remediation = maxAllow === 0
|
||
|
|
? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' })
|
||
|
|
: null;
|
||
|
|
const result = {
|
||
|
|
possibility: maxAllow,
|
||
|
|
reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0,
|
||
|
|
...(includeMeta && {
|
||
|
|
meta: {
|
||
|
|
allow: bestAllow,
|
||
|
|
deny: bestDeny,
|
||
|
|
...(evaluationPath && { evaluation: evaluationPath }),
|
||
|
|
maxDeny: maxDeny, // Keep deny info in meta for debugging
|
||
|
|
...(collectValues && allCollectedValues.length > 0 && { collectedValues: allCollectedValues }),
|
||
|
|
...(maxAllow === 0 && remediation ? { remediation } : {})
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
...(maxAllow === 0 && remediation ? { remediation } : {}),
|
||
|
|
reason: finalReason
|
||
|
|
};
|
||
|
|
|
||
|
|
// Add collectedValues at top level if there are any
|
||
|
|
if (collectValues && allCollectedValues.length > 0) {
|
||
|
|
result.collectedValues = allCollectedValues;
|
||
|
|
}
|
||
|
|
|
||
|
|
return this._maybeCacheRuleResult(result, relation, ruleCacheKey, canCacheRuleResult);
|
||
|
|
}
|
||
|
|
|
||
|
|
_getRuleResultCacheKey(userId, relation, objectId) {
|
||
|
|
return this.arbiter.keyManager.createCompositeKey(userId, relation, objectId);
|
||
|
|
}
|
||
|
|
|
||
|
|
_maybeCacheRuleResult(result, relation, cacheKey, enabled) {
|
||
|
|
if (!enabled || !cacheKey) return result;
|
||
|
|
this.decisionCache.setRule(cacheKey, result);
|
||
|
|
this.decisionCache.trackRuleKeyForRelation(relation, cacheKey);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Binary mode: Ultra-fast decisive authorization with strict thresholds
|
||
|
|
* Returns simple allow/deny decisions with minimal overhead
|
||
|
|
*/
|
||
|
|
_checkBinary(userKey, relation, objectKey, options = {}) {
|
||
|
|
const {
|
||
|
|
_visited = new Set(),
|
||
|
|
_currentRelation = null,
|
||
|
|
minAllowPossibility = 0.8,
|
||
|
|
maxDenyPossibility = 0.8,
|
||
|
|
includeMeta = false,
|
||
|
|
trackEvaluation = false
|
||
|
|
} = options;
|
||
|
|
|
||
|
|
// Track evaluation for binary mode
|
||
|
|
const evaluation = (includeMeta || trackEvaluation) ? {
|
||
|
|
type: 'binary',
|
||
|
|
userKey,
|
||
|
|
relation,
|
||
|
|
objectKey,
|
||
|
|
thresholds: { minAllowPossibility, maxDenyPossibility },
|
||
|
|
evaluationStarted: Date.now()
|
||
|
|
} : null;
|
||
|
|
|
||
|
|
const userId = this.arbiter.resolveNodeId(userKey, options);
|
||
|
|
const objectId = this.arbiter.resolveNodeId(objectKey, options);
|
||
|
|
|
||
|
|
if (userId === undefined || objectId === undefined) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
reason: 'missing_node',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for cycles using efficient approach
|
||
|
|
const useKeyedVisited = this._getVisitedMode(_visited);
|
||
|
|
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||
|
|
if (useKeyedVisited) {
|
||
|
|
if (_visited.has(visitKey)) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
reason: 'cycle',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
for (const visited of _visited) {
|
||
|
|
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
reason: 'cycle',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
_visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey });
|
||
|
|
|
||
|
|
const config = this.arbiter.relationConfigs.get(relation);
|
||
|
|
if (!config) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
reason: 'no_config',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.rulesEvaluated = 0;
|
||
|
|
evaluation.earlyTermination = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fast path for direct relations in binary mode
|
||
|
|
if (config.type === 'direct') {
|
||
|
|
let directRel = null;
|
||
|
|
let partialRel = null;
|
||
|
|
// A direct config may alias an underlying relation (config.relation);
|
||
|
|
// the checked relation name alone is the wrong lookup key.
|
||
|
|
const effectiveRelation = config.relation || relation;
|
||
|
|
if (options.partialGraphContext) {
|
||
|
|
partialRel = options.partialGraphContext.getDirectRelation(userId, effectiveRelation, objectId);
|
||
|
|
}
|
||
|
|
directRel = this.arbiter.indices.getDirectRelation(userId, effectiveRelation, objectId) || partialRel;
|
||
|
|
if (directRel) {
|
||
|
|
const allow = directRel.possibility >= minAllowPossibility;
|
||
|
|
const deny = false; // Direct relations don't have explicit deny values
|
||
|
|
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.rulesEvaluated = 1;
|
||
|
|
evaluation.evaluationCompleted = Date.now();
|
||
|
|
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: directRel.possibility,
|
||
|
|
reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation }),
|
||
|
|
allow,
|
||
|
|
deny
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.rulesEvaluated = 1;
|
||
|
|
evaluation.evaluationCompleted = Date.now();
|
||
|
|
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
reason: 'insufficient_confidence',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation }),
|
||
|
|
allow: false,
|
||
|
|
deny: false
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle logical operators with binary evaluation
|
||
|
|
if (config.union || config.intersection || config.exclusion) {
|
||
|
|
const res = this.ruleEvaluator.evaluateRule(
|
||
|
|
userId,
|
||
|
|
userKey,
|
||
|
|
objectId,
|
||
|
|
objectKey,
|
||
|
|
config,
|
||
|
|
_visited,
|
||
|
|
relation,
|
||
|
|
{
|
||
|
|
fastPath: true,
|
||
|
|
minAllowPossibility,
|
||
|
|
maxDenyPossibility,
|
||
|
|
binary: true,
|
||
|
|
...options
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.rulesEvaluated = 1;
|
||
|
|
evaluation.evaluationCompleted = Date.now();
|
||
|
|
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle both old format (possibility_allow/deny) and new format (possibility)
|
||
|
|
const resAllowPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility;
|
||
|
|
const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0;
|
||
|
|
|
||
|
|
// Binary decision based on strict thresholds
|
||
|
|
const allow = resAllowPossibility >= minAllowPossibility;
|
||
|
|
const deny = resDenyPossibility >= maxDenyPossibility;
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: resAllowPossibility || 0,
|
||
|
|
reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation }),
|
||
|
|
allow, // Keep for backwards compatibility
|
||
|
|
deny // Keep for backwards compatibility
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect and evaluate rules with early termination
|
||
|
|
const rules = this.ruleCollector.collectRules(config, null, relation);
|
||
|
|
|
||
|
|
let maxAllow = 0;
|
||
|
|
let maxDeny = 0;
|
||
|
|
|
||
|
|
for (const rule of rules) {
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.rulesEvaluated++;
|
||
|
|
}
|
||
|
|
|
||
|
|
const res = this.ruleEvaluator.evaluateRule(userId, userKey, objectId, objectKey, rule, _visited, relation, {
|
||
|
|
fastPath: true,
|
||
|
|
minAllowPossibility,
|
||
|
|
maxDenyPossibility,
|
||
|
|
binary: true,
|
||
|
|
...options
|
||
|
|
});
|
||
|
|
|
||
|
|
// Prefer the continuous possibility; binarized possibility_allow (1|0)
|
||
|
|
// must only be a fallback so reported strengths stay continuous.
|
||
|
|
const resAllowPossibility = typeof res.possibility === 'number' ? res.possibility : (res.possibility_allow !== undefined ? res.possibility_allow : 0);
|
||
|
|
const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0;
|
||
|
|
|
||
|
|
if (resAllowPossibility > maxAllow) {
|
||
|
|
maxAllow = resAllowPossibility;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (resDenyPossibility > maxDeny) {
|
||
|
|
maxDeny = resDenyPossibility;
|
||
|
|
}
|
||
|
|
|
||
|
|
// BINARY EARLY TERMINATION: Stop as soon as we hit a threshold
|
||
|
|
if (maxAllow >= minAllowPossibility) {
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.earlyTermination = true;
|
||
|
|
evaluation.terminationReason = 'allow_threshold_met';
|
||
|
|
evaluation.evaluationCompleted = Date.now();
|
||
|
|
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: maxAllow,
|
||
|
|
reason: 'allow',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation }),
|
||
|
|
allow: true, // Keep for backwards compatibility
|
||
|
|
deny: false // Keep for backwards compatibility
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (maxDeny >= maxDenyPossibility) {
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.earlyTermination = true;
|
||
|
|
evaluation.terminationReason = 'deny_threshold_met';
|
||
|
|
evaluation.evaluationCompleted = Date.now();
|
||
|
|
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
reason: 'deny',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation }),
|
||
|
|
allow: false, // Keep for backwards compatibility
|
||
|
|
deny: true // Keep for backwards compatibility
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Limit rule evaluation in binary mode for performance.
|
||
|
|
// maxBinaryRules defaults to Infinity (no cap) — set lower if you
|
||
|
|
// understand the false-denial risk for policies with many rules.
|
||
|
|
if (evaluation && evaluation.rulesEvaluated >= (options.maxBinaryRules ?? Infinity)) {
|
||
|
|
evaluation.earlyTermination = true;
|
||
|
|
evaluation.terminationReason = 'max_rules_evaluated';
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (evaluation) {
|
||
|
|
evaluation.evaluationCompleted = Date.now();
|
||
|
|
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Final binary decision
|
||
|
|
const allow = maxAllow >= minAllowPossibility;
|
||
|
|
const deny = maxDeny >= maxDenyPossibility;
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: maxAllow,
|
||
|
|
reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence',
|
||
|
|
binary: true,
|
||
|
|
...(evaluation && { evaluation }),
|
||
|
|
allow, // Keep for backwards compatibility
|
||
|
|
deny // Keep for backwards compatibility
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate cache key for direct check
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_getDirectCheckCacheKey(userKey, relation, objectKey) {
|
||
|
|
// Use composite key for better performance - keyManager handles string-to-ID conversion internally
|
||
|
|
return this.arbiter.keyManager.createCompositeKey(
|
||
|
|
this.arbiter.keyManager.getStringId(userKey),
|
||
|
|
relation,
|
||
|
|
this.arbiter.keyManager.getStringId(objectKey)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
_cacheDirectCheckResult(userKey, relation, objectKey, result) {
|
||
|
|
if (!this.decisionCache.directEnabled) return;
|
||
|
|
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||
|
|
this.decisionCache.setDirect(cacheKey, result);
|
||
|
|
}
|
||
|
|
|
||
|
|
_getVisitedMode(visited) {
|
||
|
|
if (visited.__fastKeyed !== undefined) {
|
||
|
|
return visited.__fastKeyed;
|
||
|
|
}
|
||
|
|
if (visited.size === 0) {
|
||
|
|
visited.__fastKeyed = true;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
for (const entry of visited) {
|
||
|
|
const isKeyed = typeof entry === 'string';
|
||
|
|
visited.__fastKeyed = isKeyed;
|
||
|
|
return isKeyed;
|
||
|
|
}
|
||
|
|
visited.__fastKeyed = true;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
_getVisitedKey(userId, relation, objectId) {
|
||
|
|
const relationId = this.arbiter.keyManager._getRelationId(relation);
|
||
|
|
return `${userId}|${relationId}|${objectId}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
invalidateRuleCaches(relation) {
|
||
|
|
// Invalidate ChainRule caches if it exists
|
||
|
|
if (this.ruleEvaluator && this.ruleEvaluator.ruleHandlers && this.ruleEvaluator.ruleHandlers.chain) {
|
||
|
|
this.ruleEvaluator.ruleHandlers.chain._invalidateAllChainCaches();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Invalidate other rule caches as needed
|
||
|
|
// TODO: Add invalidation for other rule types that have caches
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|