ed34df4474
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
position is now EXPANDED from the current node — the rule's base edges'
destinations, filtered by its defeaters/requirements — and traversal
continues from each discovered node. Adds _expandRuleFromSrc / direct /
logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
entity IS the object parameter (trusted(other) inside peer_trusted(user,
other)); previously only subject-var unary calls (_subjectAsObject) were
handled, so object-var unary defeaters never fired.
Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
DecisionCache rule cache now stamp entries with the graph version and treat
any mismatch as a miss — graph mutations can no longer serve stale
chain/authorization results.
Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
JSON.stringify — no string allocation or serialization on the chain-cache
hot path. Composite keys stay structured strings because the direct-check
cache pattern-invalidates by relation ID.
Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
!error && !errorMessage) never saw fn throws — vacuous. Migrated to
({ actual }) => actual !== undefined, which fails on any thrown violation
while passing legitimate null-skips. The migration immediately surfaced
two latent bugs, now fixed:
* node-manager/graph-indices skip paths returned bare undefined (falsy
sentinel) — return { skipped: true }.
* complex-graph-values-crucible expiry section rewrote values equal to the
mutation loop's last write; the engine (by design) keeps the old
timestamp on same-value rewrites so the pre-expiry grant never
materialized. Now writes guaranteed-different values.
180 lines
7.0 KiB
JavaScript
180 lines
7.0 KiB
JavaScript
/**
|
|
* rigor/authorization-config-consistency.test.js — js-rigor property tests
|
|
* for relation-config semantics and evaluation-path consistency.
|
|
*
|
|
* Properties verified:
|
|
*
|
|
* - FAST/RULE PARITY: for a direct config, the fast path (AuthorizationChecker
|
|
* direct lookup) and the rule-evaluation path agree on possibility.
|
|
* - OVERRIDE: a direct config's `relation` override is honored by BOTH
|
|
* paths — checking `can_delete` (which checks `mfa`) succeeds iff the
|
|
* `mfa` edge exists, and fails iff it is absent.
|
|
* - OVERRIDE PARITY: the override result equals a plain direct check on
|
|
* the overridden relation itself.
|
|
* - REMEDIATION: a missing injectable source surfaces unified remediation
|
|
* naming the relation and object; a present witness produces none.
|
|
* - OVERRIDE + REMEDIATION: with an injectable overridden relation, the
|
|
* denied result carries a remediation option for that relation.
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { Arbiter } from '../../src/index.js';
|
|
|
|
const EPS = 1e-9;
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function buildArbiter({ override = false, injectable = false, relation = 'mfa' } = {}) {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
if (injectable) {
|
|
arbiter.setRelationConfig(relation, {
|
|
type: 'source',
|
|
relation,
|
|
injectable: true,
|
|
provides: 'Proof'
|
|
});
|
|
}
|
|
const directConfig = { type: 'direct' };
|
|
if (override) {
|
|
directConfig.relation = relation;
|
|
}
|
|
arbiter.setRelationConfig('can_delete', directConfig);
|
|
return arbiter;
|
|
}
|
|
|
|
describe('Authorization config consistency (rigor)', () => {
|
|
it('FAST/RULE PARITY: plain direct config agrees across both evaluation paths', async () => {
|
|
async function check(p) {
|
|
const arbiter = buildArbiter();
|
|
arbiter.addRelation('user:1', 'can_delete', 'doc:1', { possibility: p });
|
|
|
|
// Fast path: default check (direct config short-circuits)
|
|
const fast = arbiter.check('user:1', 'can_delete', 'doc:1');
|
|
|
|
// Rule path: force full rule evaluation by disabling the fast path
|
|
const rule = arbiter.check('user:1', 'can_delete', 'doc:1', { fastPath: false });
|
|
|
|
if (Math.abs(fast.possibility - p) > EPS) {
|
|
fail(`fast path: expected ${p}, got ${fast.possibility}`);
|
|
}
|
|
if (Math.abs(rule.possibility - p) > EPS) {
|
|
fail(`rule path: expected ${p}, got ${rule.possibility}`);
|
|
}
|
|
return { fast, rule };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('path-parity', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 300, seed: 'authz-config-parity' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'path-parity');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `FAST/RULE parity violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('OVERRIDE: direct config relation override is honored; absent witness denies', async () => {
|
|
async function check({ hasWitness, p }) {
|
|
const arbiter = buildArbiter({ override: true, relation: 'mfa' });
|
|
// The overridden relation itself must be evaluable for the parity check
|
|
arbiter.setRelationConfig('mfa', { type: 'direct' });
|
|
if (hasWitness) {
|
|
arbiter.addRelation('user:1', 'mfa', 'doc:1', { possibility: p });
|
|
}
|
|
|
|
const withWitness = arbiter.check('user:1', 'can_delete', 'doc:1');
|
|
if (hasWitness) {
|
|
if (Math.abs(withWitness.possibility - p) > EPS) {
|
|
fail(`override with witness: expected ${p}, got ${withWitness.possibility}`);
|
|
}
|
|
} else if (withWitness.possibility !== 0) {
|
|
fail(`override without witness must deny, got ${withWitness.possibility}`);
|
|
}
|
|
|
|
// Parity: can_delete (overriding to mfa) must equal checking mfa directly
|
|
const direct = arbiter.check('user:1', 'mfa', 'doc:1');
|
|
if (Math.abs(withWitness.possibility - direct.possibility) > EPS) {
|
|
fail(`override parity: can_delete=${withWitness.possibility} vs mfa=${direct.possibility}`);
|
|
}
|
|
return { withWitness, direct };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
hasWitness: rigor.gen.boolean(),
|
|
p: rigor.gen.oneOf([0.25, 0.5, 1])
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('override-honored', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 300, seed: 'authz-config-override' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'override-honored');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `OVERRIDE contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('REMEDIATION: missing injectable witness surfaces unified remediation; present witness has none', async () => {
|
|
async function check({ hasWitness }) {
|
|
const arbiter = buildArbiter({ injectable: true, relation: 'mfa' });
|
|
arbiter.setRelationConfig('can_delete', { type: 'direct', relation: 'mfa' });
|
|
if (hasWitness) {
|
|
arbiter.addRelation('user:1', 'mfa', 'doc:1', 1.0);
|
|
}
|
|
|
|
const result = arbiter.check('user:1', 'can_delete', 'doc:1');
|
|
if (hasWitness) {
|
|
if (result.possibility !== 1) {
|
|
fail(`witness present should grant, got ${result.possibility}`);
|
|
}
|
|
if (result.remediation) {
|
|
fail(`witness present must not produce remediation, got ${JSON.stringify(result.remediation)}`);
|
|
}
|
|
} else {
|
|
if (result.possibility !== 0) {
|
|
fail(`witness missing should deny, got ${result.possibility}`);
|
|
}
|
|
const options = result.remediation?.options;
|
|
if (!Array.isArray(options) || options.length === 0) {
|
|
fail(`missing witness must produce remediation options`);
|
|
}
|
|
const mfaOption = options.find(o => o.relation === 'mfa' && o.object === 'doc:1');
|
|
if (!mfaOption) {
|
|
fail(`remediation must name relation 'mfa' and object 'doc:1', got ${JSON.stringify(options)}`);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({ hasWitness: rigor.gen.boolean() })
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('remediation-contract', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 300, seed: 'authz-config-remediation' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remediation-contract');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `REMEDIATION contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|