initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,888 @@
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user