8f863275c2
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.
894 lines
32 KiB
JavaScript
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
|
|
}
|
|
|
|
}
|