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:
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* rigor/traversal-parity.test.js — js-rigor property tests for chain
|
||||
* direction semantics, TTU multi-tuple aggregation, and the update path.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - CHAIN DIRECTION PARITY: for 2-3 step chains with arbitrary
|
||||
* out/in directions, the engine's traversal (getRelationsFromSrc /
|
||||
* getRelationsToDst) matches a BFS oracle: step 'out' walks
|
||||
* src -r-> dst, step 'in' walks src <-r- dst; path possibility is
|
||||
* the MIN along the path; multiple paths take the MAX.
|
||||
* - TTU MULTI-TUPLE PARITY: possibility = max over tuple edges of
|
||||
* min(tuplePossibility, memberPossibility) where membership is a
|
||||
* DIRECT user->group lookup.
|
||||
* - UPDATE PATH PARITY: overwriting an existing tuple
|
||||
* (addRelation on an existing src/rel/dst) is last-write-wins:
|
||||
* the check reflects the new possibility, the engine keeps exactly
|
||||
* one tuple, and indices stay consistent — for direct, chain, and
|
||||
* TTU configs alike.
|
||||
*/
|
||||
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;
|
||||
const POS = [0, 0.25, 0.5, 0.75, 1];
|
||||
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Edge universe: every possible directed edge on the 4-node graph
|
||||
// (u, m1, m2, o) for relations r1 and r2.
|
||||
const EDGE_UNIVERSE = {
|
||||
r1: [
|
||||
['user:alice', 'mid:1'],
|
||||
['mid:1', 'user:alice'],
|
||||
['mid:1', 'mid:2'],
|
||||
['doc:1', 'mid:2'],
|
||||
['mid:2', 'doc:1']
|
||||
],
|
||||
r2: [
|
||||
['mid:1', 'doc:1'],
|
||||
['doc:1', 'mid:1'],
|
||||
['mid:2', 'user:alice'],
|
||||
['user:alice', 'mid:2'],
|
||||
['user:alice', 'doc:1'],
|
||||
['mid:2', 'mid:1']
|
||||
]
|
||||
};
|
||||
|
||||
function randomEdges(rng, density = 0.5) {
|
||||
const edges = [];
|
||||
for (const rel of ['r1', 'r2']) {
|
||||
for (const [src, dst] of EDGE_UNIVERSE[rel]) {
|
||||
if (rng.next() < density) {
|
||||
edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function edgeKey(src, rel, dst) {
|
||||
return `${src}|${rel}|${dst}`;
|
||||
}
|
||||
|
||||
function chainOracle(steps, edges) {
|
||||
const em = new Map(edges.map(e => [edgeKey(e[0], e[1], e[2]), e[3]]));
|
||||
let frontier = new Map([['user:alice', 1.0]]);
|
||||
for (const step of steps) {
|
||||
const { relation, direction } = step;
|
||||
const next = new Map();
|
||||
for (const [node, p] of frontier) {
|
||||
for (const [key, ep] of em) {
|
||||
const [s, r, d] = key.split('|');
|
||||
if (r !== relation) continue;
|
||||
const matches = direction === 'out' ? s === node : d === node;
|
||||
if (!matches) continue;
|
||||
const nxt = direction === 'out' ? d : s;
|
||||
if (nxt === node) continue; // no self-loop progress
|
||||
const np = Math.min(p, ep);
|
||||
const cur = next.get(nxt);
|
||||
if (cur === undefined || np > cur) next.set(nxt, np);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
if (frontier.size === 0) break;
|
||||
}
|
||||
return frontier.get('doc:1') || 0;
|
||||
}
|
||||
|
||||
function makeChainConfig(steps) {
|
||||
return { type: 'chain', steps };
|
||||
}
|
||||
|
||||
function applyEdges(arb, edges, mode) {
|
||||
for (const [src, rel, dst, p] of edges) {
|
||||
if (mode === 'add') arb.addRelation(src, rel, dst, { possibility: p });
|
||||
else arb.removeRelation(src, rel, dst);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArbiter() {
|
||||
const arb = new Arbiter();
|
||||
for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.setRelationConfig('r2', { type: 'direct' });
|
||||
arb.setRelationConfig('owner', { type: 'direct' });
|
||||
arb.setRelationConfig('member_of', { type: 'direct' });
|
||||
return arb;
|
||||
}
|
||||
|
||||
describe('Traversal semantics parity (rigor)', () => {
|
||||
it('CHAIN DIRECTION PARITY: arbitrary out/in step mixes match the BFS oracle', async () => {
|
||||
async function check({ seed, dirs, threeSteps }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter();
|
||||
const d = dirs.split('-');
|
||||
const steps = threeSteps
|
||||
? [
|
||||
{ relation: 'r1', direction: d[0] },
|
||||
{ relation: 'r2', direction: d[1] },
|
||||
{ relation: 'r1', direction: d[2 % 2] }
|
||||
]
|
||||
: [
|
||||
{ relation: 'r1', direction: d[0] },
|
||||
{ relation: 'r2', direction: d[1] }
|
||||
];
|
||||
arb.setRelationConfig('target', makeChainConfig(steps));
|
||||
applyEdges(arb, edges, 'add');
|
||||
|
||||
const expectedP = chainOracle(steps, edges);
|
||||
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(res.possibility - expectedP) > EPS) {
|
||||
fail(`chain mismatch dirs=${JSON.stringify(dirs)} steps=${steps.length} p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
|
||||
// Mutations must keep parity
|
||||
const rels = ['r1', 'r2'];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const rel = rels[Math.floor(rng.next() * 2)];
|
||||
const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)];
|
||||
const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst);
|
||||
if (idx !== -1) {
|
||||
arb.removeRelation(src, rel, dst);
|
||||
edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
edges.push([src, rel, dst, p]);
|
||||
}
|
||||
const eP = chainOracle(steps, edges);
|
||||
const r = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(r.possibility - eP) > EPS) {
|
||||
fail(`chain mutation mismatch dirs=${JSON.stringify(dirs)} p=${eP} got=${r.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
}
|
||||
return { steps: steps.length };
|
||||
}
|
||||
|
||||
const dirPairs = ['out-out', 'out-in', 'in-out', 'in-in'];
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 100000),
|
||||
dirs: rigor.gen.oneOf(dirPairs),
|
||||
threeSteps: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('chain-direction-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500, seed: 'traversal-chain-direction' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-direction-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `chain direction parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('TTU MULTI-TUPLE PARITY: max over tuples of min(tuple, membership)', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const arb = buildArbiter();
|
||||
arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||||
const tuples = [
|
||||
['doc:1', 'group:eng'],
|
||||
['doc:1', 'group:design']
|
||||
];
|
||||
arb.addNode('group:eng', 'group');
|
||||
arb.addNode('group:design', 'group');
|
||||
const edges = [];
|
||||
let expectedP = 0;
|
||||
for (const [doc, grp] of tuples) {
|
||||
if (rng.next() < 0.7) {
|
||||
const tp = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(doc, 'owner', grp, { possibility: tp });
|
||||
edges.push([doc, 'owner', grp, tp]);
|
||||
let mp = 0;
|
||||
if (rng.next() < 0.8) {
|
||||
mp = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation('user:alice', 'member_of', grp, { possibility: mp });
|
||||
edges.push(['user:alice', 'member_of', grp, mp]);
|
||||
}
|
||||
expectedP = Math.max(expectedP, Math.min(tp, mp));
|
||||
}
|
||||
}
|
||||
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(res.possibility - expectedP) > EPS) {
|
||||
fail(`ttu mismatch p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
|
||||
// Mutation: flip one membership
|
||||
const grp = tuples[Math.floor(rng.next() * 2)][1];
|
||||
const hasMember = edges.some(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
|
||||
if (hasMember) {
|
||||
arb.removeRelation('user:alice', 'member_of', grp);
|
||||
const idx = edges.findIndex(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
|
||||
if (idx !== -1) edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation('user:alice', 'member_of', grp, { possibility: p });
|
||||
edges.push(['user:alice', 'member_of', grp, p]);
|
||||
}
|
||||
let eP = 0;
|
||||
const tupleMap = new Map();
|
||||
for (const [s, r, d, p] of edges) {
|
||||
if (r === 'owner') tupleMap.set(d, p);
|
||||
}
|
||||
for (const [grp2, tp] of tupleMap) {
|
||||
const mem = edges.find(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp2);
|
||||
eP = Math.max(eP, Math.min(tp, mem ? mem[3] : 0));
|
||||
}
|
||||
const r2 = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(r2.possibility - eP) > EPS) {
|
||||
fail(`ttu mutation mismatch p=${eP} got=${r2.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
return { edges: edges.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 100000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('ttu-multi-tuple-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1200, seed: 'traversal-ttu-multituple' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-multi-tuple-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `ttu parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('UPDATE PATH PARITY: overwrites are last-write-wins with a single tuple and fresh checks', async () => {
|
||||
async function check({ seed, kind }) {
|
||||
const rng = mulberry32(seed);
|
||||
const arb = buildArbiter();
|
||||
let target;
|
||||
if (kind === 0) {
|
||||
arb.setRelationConfig('target', { type: 'direct', relation: 'r1' });
|
||||
target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
|
||||
} else if (kind === 1) {
|
||||
arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] });
|
||||
target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
|
||||
} else {
|
||||
arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||||
arb.addNode('group:eng', 'group');
|
||||
target = { check: (u, o) => arb.check(u, 'target', o), base: 'owner' };
|
||||
}
|
||||
|
||||
const src = kind === 2 ? 'doc:1' : 'user:alice';
|
||||
const dst = kind === 0 ? 'mid:1' : kind === 1 ? 'mid:1' : 'group:eng';
|
||||
// Warm with the first value
|
||||
const p0 = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, target.base, dst, { possibility: p0 });
|
||||
|
||||
let lastP = p0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, target.base, dst, { possibility: p });
|
||||
lastP = p;
|
||||
}
|
||||
|
||||
// Exactly one tuple in the engine (no duplicates from overwrites)
|
||||
const count = arb.relations.filter(r => r.rel === target.base && r.src === arb.resolveNodeId(src) && r.dst === arb.resolveNodeId(dst)).length; if (count !== 1) {
|
||||
fail(`overwrite left ${count} tuples for ${target.base} (kind=${kind})`);
|
||||
}
|
||||
|
||||
if (kind === 0) {
|
||||
const res = arb.check(src, 'target', kind === 0 ? 'mid:1' : 'doc:1');
|
||||
if (Math.abs(res.possibility - lastP) > EPS) {
|
||||
fail(`update path stale: expected ${lastP}, got ${res.possibility} (kind=${kind})`);
|
||||
}
|
||||
} else if (kind === 1) {
|
||||
// chain: seed an r2 edge so the path exists
|
||||
arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 });
|
||||
const res = arb.check(src, 'target', 'doc:1');
|
||||
if (Math.abs(res.possibility - lastP) > EPS) {
|
||||
fail(`chain update path stale: expected ${lastP}, got ${res.possibility}`);
|
||||
}
|
||||
} else {
|
||||
arb.addRelation('user:alice', 'member_of', 'group:eng', { possibility: 1 });
|
||||
const res = arb.check('user:alice', 'target', 'doc:1');
|
||||
if (Math.abs(res.possibility - lastP) > EPS) {
|
||||
fail(`ttu update path stale: expected ${lastP}, got ${res.possibility}`);
|
||||
}
|
||||
}
|
||||
return { lastP };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 50000),
|
||||
kind: rigor.gen.int(0, 2)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('update-path-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 600, seed: 'traversal-update-path' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'update-path-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `update path parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user