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:
John Dvorak
2026-07-31 13:44:06 -07:00
commit 717ae1031e
373 changed files with 654131 additions and 0 deletions
+228
View File
@@ -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;