Files
core/src/authorization/AuthorizationChecker.js
T
John Dvorak 8f863275c2 js-rigor: fix binary mode dropping partial graphs; binary-partial parity campaign
The binary branch of AuthorizationChecker.check rebuilt its options with a
fixed six-field object, silently discarding partialGraphContext (and any
other caller option) — binary checks denied grants the normal path
allowed. Now spreads all caller options through. binary-partial-parity
pins: partial grants above the threshold allow, below deny, persistent
wins over partial, and binary decisions agree with normal decisions on
the same overlay.
2026-07-31 15:31:34 -07:00

894 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,
// Preserve every caller option (partialGraphContext, clientStateId,
// ...) — the binary path previously rebuilt a six-field object and
// silently dropped the partial graph, so binary checks denied
// grants that the normal path allowed.
...options
});
}
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
}
}