/** * 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`); }); });