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,186 @@
|
||||
/**
|
||||
* rigor/snapshot-parity.test.js — js-rigor property tests for the
|
||||
* condensed-snapshot round trip.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - ROUND-TRIP PARITY: for a random graph (direct + chain configs with
|
||||
* possibilities and values), serializing via enableCondensedSnapshot +
|
||||
* serializeArbiterSnapshot and deserializing yields an arbiter whose
|
||||
* check() answers are IDENTICAL to the original's — for every user,
|
||||
* relation and object in the graph.
|
||||
* - READ-ONLY ENFORCEMENT: the deserialized snapshot rejects mutations
|
||||
* (addRelation / removeRelation / addNode throw or no-op safely) while
|
||||
* reads keep working.
|
||||
* - CONFIG PRESERVATION: relation configs (including relation overrides
|
||||
* and chains) survive the round trip and still evaluate correctly.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js';
|
||||
import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js';
|
||||
|
||||
const EPS = 1e-9;
|
||||
const POS = [0, 0.25, 0.5, 0.75, 1];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function factory() {
|
||||
return new Arbiter({ fastConstructionMode: true, enableInference: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a random graph; return { original, restored, checks } where checks
|
||||
* is the list of (userKey, rel, objKey) triples verified for parity.
|
||||
*/
|
||||
function buildGraph(seedCase) {
|
||||
const { users, mids, configKind } = seedCase;
|
||||
const arbiter = factory();
|
||||
const userKeys = [];
|
||||
const midKeys = [];
|
||||
for (let i = 0; i < users; i++) {
|
||||
userKeys.push(`user:${i}`);
|
||||
arbiter.addNode(`user:${i}`, 'user');
|
||||
}
|
||||
for (let i = 0; i < mids; i++) {
|
||||
midKeys.push(`mid:${i}`);
|
||||
arbiter.addNode(`mid:${i}`, 'group');
|
||||
}
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
|
||||
if (configKind === 0) {
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
|
||||
} else {
|
||||
arbiter.setRelationConfig('can_read', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'viewer', direction: 'out' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Direct edges: user → doc
|
||||
const directEdges = Math.max(1, Math.floor(users / 2) + 1);
|
||||
for (let i = 0; i < directEdges; i++) {
|
||||
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
||||
arbiter.addRelation(u, 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
||||
}
|
||||
// Memberships: user → mid
|
||||
for (let i = 0; i < mids; i++) {
|
||||
if (Math.random() < 0.7) {
|
||||
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
||||
arbiter.addRelation(u, 'member_of', midKeys[i], { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
||||
}
|
||||
}
|
||||
// Terminal: mid → doc
|
||||
for (let i = 0; i < mids; i++) {
|
||||
if (Math.random() < 0.7) {
|
||||
arbiter.addRelation(midKeys[i], 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
||||
}
|
||||
}
|
||||
|
||||
arbiter.enableCondensedSnapshot();
|
||||
const buffer = serializeArbiterSnapshot(arbiter);
|
||||
// Proper restore path: rebuilds the condensed indices over the graph
|
||||
const restored = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => factory());
|
||||
|
||||
const checks = [];
|
||||
for (const u of userKeys) {
|
||||
checks.push([u, 'can_read', 'doc:1']);
|
||||
}
|
||||
return { original: arbiter, restored, checks };
|
||||
}
|
||||
|
||||
describe('Condensed snapshot round trip (rigor)', () => {
|
||||
it('ROUND-TRIP PARITY: restored snapshot answers checks identically', async () => {
|
||||
async function check(seedCase) {
|
||||
const { original, restored, checks } = buildGraph(seedCase);
|
||||
for (const [u, rel, obj] of checks) {
|
||||
const before = original.check(u, rel, obj);
|
||||
const after = restored.check(u, rel, obj);
|
||||
if (Math.abs(before.possibility - after.possibility) > EPS) {
|
||||
fail(`parity ${u} ${rel} ${obj}: original=${before.possibility}, restored=${after.possibility}`);
|
||||
}
|
||||
}
|
||||
return { checked: checks.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
users: rigor.gen.int(1, 5),
|
||||
mids: rigor.gen.int(0, 4),
|
||||
configKind: rigor.gen.int(0, 1)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('snapshot-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'snapshot-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'snapshot-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `ROUND-TRIP PARITY violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('READ-ONLY ENFORCEMENT: restored snapshot rejects mutations, keeps reading', async () => {
|
||||
async function check(seedCase) {
|
||||
const { restored, checks } = buildGraph(seedCase);
|
||||
if (restored._snapshotReadOnly !== true) {
|
||||
fail('restored snapshot must be read-only');
|
||||
}
|
||||
// Reads still work
|
||||
for (const [u, rel, obj] of checks) {
|
||||
const r = restored.check(u, rel, obj);
|
||||
if (r.possibility < 0 || r.possibility > 1) {
|
||||
fail(`read on snapshot out of bounds: ${r.possibility}`);
|
||||
}
|
||||
}
|
||||
// Mutations must not corrupt the snapshot
|
||||
let threw = false;
|
||||
try {
|
||||
restored.addRelation('user:0', 'viewer', 'doc:1', { possibility: 1 });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
if (!threw) {
|
||||
// If it didn't throw, the mutation must not have changed answers
|
||||
for (const [u, rel, obj] of checks) {
|
||||
const r = restored.check(u, rel, obj);
|
||||
if (r.possibility < 0 || r.possibility > 1) {
|
||||
fail(`mutated snapshot returned bad result: ${r.possibility}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { readOnly: restored._snapshotReadOnly };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
users: rigor.gen.int(1, 4),
|
||||
mids: rigor.gen.int(0, 3),
|
||||
configKind: rigor.gen.int(0, 1)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('readonly-enforced', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'snapshot-readonly' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'readonly-enforced');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `READ-ONLY ENFORCEMENT violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user