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,228 @@
|
||||
/**
|
||||
* DecisionCache — accidental-state port for authorization decision caching.
|
||||
*
|
||||
* Wraps the directCheckCache + ruleResultCache + reverse-indexes
|
||||
* + dependencyIndex + TTL settings that the engine uses to short-circuit
|
||||
* repeated authorization decisions. Implementing this port gives test
|
||||
* code an explicit, dependency-injectable seam — and frees the engine
|
||||
* from the rule that "function signature lies" (RF-03 closure).
|
||||
*
|
||||
* The port exposes ONLY the surface used by the engine:
|
||||
* - getDirect(key) / setDirect(key, result)
|
||||
* - getRule(ruleKey) / setRule(ruleKey, result)
|
||||
* - invalidateByRelation(relation)
|
||||
* - invalidateByNodeKey(nodeKey) // for NodeManager cache leak fix
|
||||
* - invalidateAll()
|
||||
*
|
||||
* All TTL / clock concerns move into the port implementation so the
|
||||
* AuthorizationChecker no longer reads wall-clock from its hot path.
|
||||
*
|
||||
* The default `ArbiterDecisionCache` implementation forwards every call
|
||||
* to the corresponding field on the Arbiter instance — preserving the
|
||||
* existing behavior of every test that inspects `arbiter.directCheckCache`
|
||||
* directly while moving the read site out of AuthorizationChecker.
|
||||
*/
|
||||
export class DecisionCache {
|
||||
/**
|
||||
* @param {object} arbiter The Arbiter instance whose caches we wrap.
|
||||
* `null` produces a fully inert cache (every
|
||||
* get returns undefined, every set is a no-op)
|
||||
* — useful for tests that want to disable caching
|
||||
* without constructing a full Arbiter.
|
||||
* @param {object} [options]
|
||||
* @param {(unit?:string) => number} [options.clock] Time source for TTL
|
||||
* checks. Defaults to Date.now (ms) but tests
|
||||
* can inject a fake clock.
|
||||
*/
|
||||
constructor(arbiter, options = {}) {
|
||||
this.arbiter = arbiter;
|
||||
this.clock = options.clock || (() => Date.now());
|
||||
this._enabled = !!arbiter;
|
||||
}
|
||||
|
||||
/** True if this cache can store anything. */
|
||||
get enabled() {
|
||||
if (!this._enabled) return false;
|
||||
const a = this.arbiter;
|
||||
return !a.disableCaching;
|
||||
}
|
||||
|
||||
/** True if direct (single-step) caching is active. */
|
||||
get directEnabled() {
|
||||
if (!this.enabled) return false;
|
||||
return !!(this.arbiter.directCheckCache) && !this.arbiter.disableDirectCaching;
|
||||
}
|
||||
|
||||
/** True if rule-result caching is active. */
|
||||
get ruleEnabled() {
|
||||
if (!this.enabled) return false;
|
||||
return !!this.arbiter.ruleResultCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a previously-cached direct-check result.
|
||||
* Returns undefined on miss / disabled.
|
||||
* Distinguishes "expired" from "miss" via the second tuple element.
|
||||
*
|
||||
* @returns {[result, status]} status is 'hit' | 'expired' | 'miss' | 'disabled'
|
||||
*/
|
||||
peekDirect(cacheKey) {
|
||||
if (!this.directEnabled) return [undefined, 'disabled'];
|
||||
const entry = this.arbiter.directCheckCache.get(cacheKey);
|
||||
if (!entry) return [undefined, 'miss'];
|
||||
if (this.clock() - entry.timestamp >= this.arbiter.directCheckCacheTTL) {
|
||||
return [entry.result, 'expired'];
|
||||
}
|
||||
return [entry.result, 'hit'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified form: returns just the result on hit, undefined otherwise.
|
||||
*/
|
||||
getDirect(cacheKey) {
|
||||
const [result, status] = this.peekDirect(cacheKey);
|
||||
return status === 'hit' ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a direct-check result. Caller is responsible for key construction
|
||||
* (so the cache key derivation logic stays in AuthorizationChecker where
|
||||
* the schema lives).
|
||||
*/
|
||||
setDirect(cacheKey, result) {
|
||||
if (!this.directEnabled) return;
|
||||
this.arbiter.directCheckCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: this.clock()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a previously-cached rule-evaluation result.
|
||||
* Returns undefined on miss / disabled / expired.
|
||||
*/
|
||||
getRule(ruleCacheKey) {
|
||||
if (!this.ruleEnabled) return undefined;
|
||||
const entry = this.arbiter.ruleResultCache.get(ruleCacheKey);
|
||||
if (!entry) return undefined;
|
||||
if (this.clock() - entry.timestamp >= this.arbiter.ruleResultCacheTTL) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
setRule(ruleCacheKey, result) {
|
||||
if (!this.ruleEnabled) return;
|
||||
this.arbiter.ruleResultCache.set(ruleCacheKey, {
|
||||
result,
|
||||
timestamp: this.clock()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a cache key as belonging to a particular relation so that
|
||||
* future invalidateByRelation(relation) calls can find it.
|
||||
*/
|
||||
trackRuleKeyForRelation(relation, cacheKey) {
|
||||
const a = this.arbiter;
|
||||
if (!a.ruleResultCache || !cacheKey) return;
|
||||
let set = a.ruleResultCacheKeysByRelation.get(relation);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
a.ruleResultCacheKeysByRelation.set(relation, set);
|
||||
}
|
||||
set.add(cacheKey);
|
||||
a.ruleResultCacheRelationByKey.set(cacheKey, relation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every direct + rule-result cache entry that could
|
||||
* have been affected by a relation change. This mirrors the old
|
||||
* Arbiter._invalidateDirectCheckCache + invalidateRuleResultCacheByRelation
|
||||
* pair, fused into one port-level call.
|
||||
*/
|
||||
invalidateByRelation(relation) {
|
||||
const a = this.arbiter;
|
||||
if (!a) return;
|
||||
if (a.directCheckCache) {
|
||||
a._invalidateDirectCheckCache?.(/*srcKey*/ null, relation, /*dstKey*/ null);
|
||||
}
|
||||
a.invalidateRuleResultCacheByRelation?.(relation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every direct-check entry whose composite key contains
|
||||
* a given node id. Replaces the leaky `cacheKey.includes(String(nodeId))`
|
||||
* walk that NodeManager used to perform. Now the cache file is the
|
||||
* only place that knows its own storage layout.
|
||||
*
|
||||
* Requires the cache implementation to expose either a key iterator
|
||||
* (`keys()`) or a pattern-invalidate method (`invalidateByPattern`).
|
||||
* The local @tenere/hyperbolic-lru@1.0.3 provides both. The previous
|
||||
* npm hyperbolic-lru@1.0.2 exposed neither, making this method a no-op.
|
||||
*/
|
||||
invalidateByNodeKey(nodeKey) {
|
||||
const a = this.arbiter;
|
||||
if (!a?.directCheckCache) return;
|
||||
const nodeId = a.nodeIdByKey?.get(nodeKey);
|
||||
if (nodeId === undefined) return;
|
||||
const cache = a.directCheckCache;
|
||||
|
||||
// Pattern-based invalidation is the safest path — the cache file
|
||||
// owns its storage layout and decides how to enumerate keys.
|
||||
if (typeof cache.invalidateByPattern === 'function') {
|
||||
// Match a nodeId that appears in any position of the composite
|
||||
// key. The composite key format is `${srcId}|${rel}|${dstId}` and
|
||||
// components are pipe-delimited, so a digit-boundary regex is
|
||||
// safer than a plain substring match.
|
||||
const escaped = String(nodeId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`(?:^|\\|)${escaped}(?:\\||$)`);
|
||||
cache.invalidateByPattern(pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: explicit key iteration. HyperbolicLRUCache does not
|
||||
// expose `keys()`, so this branch is unreachable for that cache
|
||||
// family. If a future cache implementation exposes iteration,
|
||||
// we walk it without leaking storage-layout details.
|
||||
if (typeof cache.keys === 'function') {
|
||||
for (const key of cache.keys()) {
|
||||
if (typeof key === 'string' && key.includes(String(nodeId))) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush every cache. Equivalent to the old
|
||||
* `disableCaching = true` test path.
|
||||
*/
|
||||
invalidateAll() {
|
||||
const a = this.arbiter;
|
||||
if (!a) return;
|
||||
a.directCheckCache?.clear?.();
|
||||
a.invalidateAllRuleResultCache?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NullDecisionCache — every operation is a no-op. Use in tests that want
|
||||
* a pure decision flow with no accidental state at all.
|
||||
*/
|
||||
export class NullDecisionCache {
|
||||
constructor() { this._enabled = true; }
|
||||
get enabled() { return false; }
|
||||
get directEnabled() { return false; }
|
||||
get ruleEnabled() { return false; }
|
||||
getDirect() { return undefined; }
|
||||
setDirect() {}
|
||||
getRule() { return undefined; }
|
||||
setRule() {}
|
||||
trackRuleKeyForRelation() {}
|
||||
invalidateByRelation() {}
|
||||
invalidateByNodeKey() {}
|
||||
invalidateAll() {}
|
||||
}
|
||||
|
||||
export default DecisionCache;
|
||||
Reference in New Issue
Block a user