Files
core/src/authorization/DecisionCache.js
T

216 lines
7.7 KiB
JavaScript
Raw Normal View History

/**
* 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;
// Precedence: an explicit cache-level clock wins; else the arbiter's
// injected clock (options.clock on the Arbiter); else the wall clock.
this.clock = options.clock || ((arbiter && typeof arbiter.clock === 'function') ? arbiter.clock.bind(arbiter) : (() => 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;
// A graph mutation since the entry was computed invalidates it (stale
// derived result). _graphVersion increments on every relation mutation.
if (entry.graphVersion !== undefined && entry.graphVersion !== (this.arbiter._graphVersion ?? 0)) {
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(),
graphVersion: this.arbiter._graphVersion ?? 0
});
}
/**
* 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;
// Keys are rolling hashes (not pipe-delimited strings), so node-level
// invalidation uses the per-node key index populated at cache-set time.
a._invalidateDirectCheckCacheByNode?.(nodeId);
}
/**
* Flush every cache. Equivalent to the old
* `disableCaching = true` test path.
*/
invalidateAll() {
const a = this.arbiter;
if (!a) return;
a.directCheckCache?.clear?.();
a.directCheckCacheKeysByRelation?.clear?.();
a.directCheckCacheKeysByNode?.clear?.();
a.directKeyToRelations?.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;