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.
297 lines
12 KiB
JavaScript
297 lines
12 KiB
JavaScript
/**
|
|
* rigor/authorization-graph.test.js — js-rigor property tests for the
|
|
* authorization graph semantics.
|
|
*
|
|
* Properties verified (the core authorization-graph contract):
|
|
*
|
|
* - DIRECT: an existing edge grants with EXACTLY its possibility;
|
|
* a different relation on the same pair denies (0).
|
|
* - BOUNDS: every check result possibility is ∈ [0, 1].
|
|
* - ABSENT: no edges → 0 for any relation.
|
|
* - CHAIN (weakest link): a chain's possibility equals the MIN of the
|
|
* edge possibilities along the traversed path (transitivity holds).
|
|
* - MULTI-PATH (disjunctive): with parallel paths the possibility is the
|
|
* MAX over paths of the per-path minimum.
|
|
* - TUPLE-TO-USERSET: group membership grants the group's owned objects
|
|
* at the weakest-link possibility.
|
|
* - MUTATION: removing an edge invalidates a previously-granting check
|
|
* (no stale cache grant).
|
|
*
|
|
* Each property runs through js-rigor's generator + bandit pipeline, so
|
|
* boundary values (possibility 0/1, self-loops, multi-hop chains) are
|
|
* exercised automatically, with shrinking on failure.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
describe('Authorization graph semantics (rigor)', () => {
|
|
it('DIRECT: existing edge grants with its exact possibility; other relations deny', async () => {
|
|
async function check({ p, wrongRel }) {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:alice', 'user');
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
arbiter.setRelationConfig('can_write', { type: 'direct' });
|
|
arbiter.addRelation('user:alice', 'can_read', 'doc:secret', { possibility: p });
|
|
|
|
const grant = arbiter.check('user:alice', 'can_read', 'doc:secret');
|
|
if (Math.abs(grant.possibility - p) > EPS) {
|
|
fail(`direct grant: expected ${p}, got ${grant.possibility}`);
|
|
}
|
|
if (grant.possibility < 0 || grant.possibility > 1) {
|
|
fail(`possibility out of bounds: ${grant.possibility}`);
|
|
}
|
|
const deny = arbiter.check('user:alice', 'can_write', 'doc:secret');
|
|
if (deny.possibility !== 0) {
|
|
fail(`different relation should deny, got ${deny.possibility}`);
|
|
}
|
|
return { grant, deny };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
p: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
|
wrongRel: rigor.gen.boolean()
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('direct-exact', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 400, seed: 'authz-graph-direct' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'direct-exact');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `DIRECT contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('BOUNDS + ABSENT: no edges → 0; every possibility ∈ [0,1]', async () => {
|
|
async function check(nodes) {
|
|
const arbiter = new Arbiter();
|
|
const keys = [];
|
|
for (let i = 0; i < nodes; i++) {
|
|
keys.push(`node:${i}`);
|
|
arbiter.addNode(`node:${i}`, 'entity');
|
|
}
|
|
arbiter.setRelationConfig('rel_x', { type: 'direct' });
|
|
const src = keys[0];
|
|
const dst = keys[keys.length - 1];
|
|
const result = arbiter.check(src, 'rel_x', dst);
|
|
if (result.possibility !== 0) {
|
|
fail(`empty graph must deny, got ${result.possibility}`);
|
|
}
|
|
if (result.possibility < 0 || result.possibility > 1) {
|
|
fail(`possibility out of bounds: ${result.possibility}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(rigor.gen.int(2, 6)))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('absent-denies', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 300, seed: 'authz-graph-absent' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'absent-denies');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `ABSENT contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('CHAIN (weakest link): transitivity with min possibility along the path', async () => {
|
|
async function check({ p1, p2 }) {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:alice', 'user');
|
|
arbiter.addNode('group:eng', 'group');
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
arbiter.setRelationConfig('can_access', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member_of', direction: 'out' },
|
|
{ relation: 'group_reads', direction: 'out' }
|
|
]
|
|
});
|
|
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: p1 });
|
|
arbiter.addRelation('group:eng', 'group_reads', 'doc:secret', { possibility: p2 });
|
|
|
|
const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
|
|
const expected = Math.min(p1, p2);
|
|
if (Math.abs(result.possibility - expected) > EPS) {
|
|
fail(`chain: expected ${expected} (min(${p1},${p2})), got ${result.possibility}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
p1: rigor.gen.oneOf([0, 0.1, 0.5, 0.9, 1]),
|
|
p2: rigor.gen.oneOf([0, 0.1, 0.5, 0.9, 1])
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('weakest-link', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 400, seed: 'authz-graph-chain' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'weakest-link');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `CHAIN contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('MULTI-PATH (disjunctive): max over paths of the per-path minimum', async () => {
|
|
async function check({ p1a, p2a, p1b, p2b }) {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:alice', 'user');
|
|
arbiter.addNode('mid:1', 'group');
|
|
arbiter.addNode('mid:2', 'group');
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
arbiter.setRelationConfig('can_access', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member_of', direction: 'out' },
|
|
{ relation: 'group_reads', direction: 'out' }
|
|
]
|
|
});
|
|
// Path 1: alice → mid:1 → doc
|
|
arbiter.addRelation('user:alice', 'member_of', 'mid:1', { possibility: p1a });
|
|
arbiter.addRelation('mid:1', 'group_reads', 'doc:secret', { possibility: p2a });
|
|
// Path 2: alice → mid:2 → doc
|
|
arbiter.addRelation('user:alice', 'member_of', 'mid:2', { possibility: p1b });
|
|
arbiter.addRelation('mid:2', 'group_reads', 'doc:secret', { possibility: p2b });
|
|
|
|
const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
|
|
const expected = Math.max(Math.min(p1a, p2a), Math.min(p1b, p2b));
|
|
if (Math.abs(result.possibility - expected) > EPS) {
|
|
fail(`multi-path: expected ${expected}, got ${result.possibility}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
p1a: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
|
p2a: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
|
p1b: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
|
p2b: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('disjunctive-max', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 500, seed: 'authz-graph-multipath' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'disjunctive-max');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `MULTI-PATH contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('TUPLE-TO-USERSET: group membership grants owned objects at weakest-link possibility', async () => {
|
|
async function check({ pm, po }) {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:alice', 'user');
|
|
arbiter.addNode('group:eng', 'group');
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
|
arbiter.setRelationConfig('owner', { type: 'direct' });
|
|
arbiter.setRelationConfig('can_access', {
|
|
type: 'tuple_to_userset',
|
|
tuplesetRelation: 'owner',
|
|
computedRelation: 'member_of',
|
|
reverse: false
|
|
});
|
|
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm });
|
|
// Tupleset edge: object → group via 'owner' (document owns the group),
|
|
// matching the Zanzibar tupleset convention used by the engine.
|
|
arbiter.addRelation('doc:secret', 'owner', 'group:eng', { possibility: po });
|
|
|
|
const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
|
|
const expected = Math.min(pm, po);
|
|
if (Math.abs(result.possibility - expected) > EPS) {
|
|
fail(`tuple-to-userset: expected ${expected}, got ${result.possibility}`);
|
|
}
|
|
// A user outside the group must not gain access via the same object
|
|
arbiter.addNode('user:eve', 'user');
|
|
const denied = arbiter.check('user:eve', 'can_access', 'doc:secret');
|
|
if (denied.possibility !== 0) {
|
|
fail(`non-member must deny, got ${denied.possibility}`);
|
|
}
|
|
return { result, denied };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
pm: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
|
po: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('tus-weakest-link', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 400, seed: 'authz-graph-tus' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'tus-weakest-link');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `TUPLE-TO-USERSET contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('MUTATION: removing an edge revokes a previously-granting check (no stale cache)', async () => {
|
|
async function check({ p }) {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:alice', 'user');
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
arbiter.addRelation('user:alice', 'can_read', 'doc:secret', { possibility: p });
|
|
|
|
// Warm the caches with a granting check
|
|
const before = arbiter.check('user:alice', 'can_read', 'doc:secret');
|
|
if (before.possibility <= 0) {
|
|
fail(`setup: expected grant, got ${before.possibility}`);
|
|
}
|
|
|
|
// Mutate the graph: remove the edge, then re-check
|
|
arbiter.removeRelation('user:alice', 'can_read', 'doc:secret');
|
|
const after = arbiter.check('user:alice', 'can_read', 'doc:secret');
|
|
if (after.possibility !== 0) {
|
|
fail(`revoked access still granted: ${after.possibility}`);
|
|
}
|
|
return { before, after };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({ p: rigor.gen.oneOf([0.25, 0.5, 0.75, 1]) })
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('revoke-invalidates', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ effort: 300, seed: 'authz-graph-mutation' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'revoke-invalidates');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `MUTATION contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|