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,206 @@
|
||||
/**
|
||||
* rigor/protocol-snapshot-lifecycle.test.js — snapshot lifecycle protocol
|
||||
* as a stateful rigor.object campaign with gated invariants
|
||||
* (before/after/between/when/unless).
|
||||
*
|
||||
* Lifecycle protocol (pinned from engine probes):
|
||||
* - add/remove/setConfig: allowed while LIVE.
|
||||
* - enable() (enableCondensedSnapshot): freezes RELATION writes
|
||||
* (add/remove now THROW) but queries keep working; configs stay
|
||||
* mutable; double-enable is idempotent.
|
||||
* - restore(): serializes the condensed graph and rebuilds a fresh
|
||||
* read-only arbiter; relation writes keep throwing; configs remain
|
||||
* mutable and are NOT part of the snapshot; checks reflect the
|
||||
* snapshot values (16-bit quantized).
|
||||
* - between enable() and restore(): queries serve live values exactly.
|
||||
* - after restore(): queries serve quantized snapshot values.
|
||||
* - double-restore (snapshot-of-snapshot) is supported.
|
||||
*
|
||||
* The wrapper mirrors the protocol independently (flag + tuple mirror);
|
||||
* every method runs the REAL engine and rethrows engine errors, so the
|
||||
* invariants compare engine behavior against the mirror's prediction.
|
||||
*/
|
||||
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 TOL = 0.5 / 65535 + 1e-9;
|
||||
const NODES = 6;
|
||||
const nodeKey = (id) => (id < 2 ? `u:${id}` : id === 2 ? 'g:0' : `doc:${id - 3}`);
|
||||
|
||||
function mirrorCheck(tuples, configs, src, rel, dst) {
|
||||
const srcKey = nodeKey(src);
|
||||
const dstKey = nodeKey(dst);
|
||||
const key = (s, r, d) => `${s}|${r}|${d}`;
|
||||
const p = (s, r, d) => tuples.get(key(s, r, d)) ?? 0;
|
||||
if (rel === 'can_read') {
|
||||
return p(srcKey, 'owner', dstKey);
|
||||
}
|
||||
if (rel === 'can_access') {
|
||||
let best = 0;
|
||||
for (let m = 0; m < NODES; m++) {
|
||||
best = Math.max(best, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey)));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function makeWrapper() {
|
||||
const arbiter = new Arbiter();
|
||||
for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'reads', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
const tuples = new Map();
|
||||
const tupleKey = (src, rel, dst) => `${src}|${rel}|${dst}`;
|
||||
const ops = [];
|
||||
|
||||
const wrapper = {
|
||||
flag: 'live',
|
||||
buffer: null,
|
||||
frozen: null,
|
||||
engine: arbiter,
|
||||
ops,
|
||||
add(src, rel, dst, p) {
|
||||
const key = nodeKey(src);
|
||||
const dstKey = nodeKey(dst);
|
||||
arbiter.addRelation(key, rel, dstKey, { possibility: p }); // throws when frozen
|
||||
tuples.set(tupleKey(key, rel, dstKey), p);
|
||||
return { ok: true };
|
||||
},
|
||||
remove(src, rel, dst) {
|
||||
arbiter.removeRelation(nodeKey(src), rel, nodeKey(dst)); // throws when frozen
|
||||
tuples.delete(tupleKey(nodeKey(src), rel, nodeKey(dst)));
|
||||
return { ok: true };
|
||||
},
|
||||
setConfig(which) {
|
||||
if (which === 'chain') {
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'reads', direction: 'out' }
|
||||
]
|
||||
});
|
||||
} else {
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
enable() {
|
||||
if (wrapper.flag === 'restored') {
|
||||
throw new Error('enable after restore is out of contract');
|
||||
}
|
||||
arbiter.enableCondensedSnapshot();
|
||||
wrapper.flag = 'enabled';
|
||||
return { ok: true };
|
||||
},
|
||||
restore() {
|
||||
const buf = serializeArbiterSnapshot(arbiter); // throws when not enabled
|
||||
wrapper.frozen = new Map(tuples);
|
||||
wrapper.buffer = buf;
|
||||
const next = ArbiterSnapshot.fromSnapshotBinary(buf, {}, () => new Arbiter());
|
||||
wrapper.engine = next;
|
||||
wrapper.flag = 'restored';
|
||||
return { ok: true, bytes: buf.byteLength };
|
||||
},
|
||||
check(src, rel, dst) {
|
||||
const result = wrapper.engine.check(nodeKey(src), rel, nodeKey(dst));
|
||||
const expected = mirrorCheck(tuples, null, src, rel, dst);
|
||||
return { engine: result.possibility, expected, flag: wrapper.flag };
|
||||
},
|
||||
clone() {
|
||||
const fresh = makeWrapper();
|
||||
for (const op of ops) {
|
||||
const [name, ...args] = op;
|
||||
if (name === 'check') fresh.check(...args);
|
||||
else fresh[name](...args);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
};
|
||||
|
||||
const record = (name, fn) => {
|
||||
return (...args) => {
|
||||
const result = fn(...args);
|
||||
ops.push([name, ...args]);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
wrapper.add = record('add', wrapper.add);
|
||||
wrapper.remove = record('remove', wrapper.remove);
|
||||
wrapper.setConfig = record('setConfig', wrapper.setConfig);
|
||||
wrapper.enable = record('enable', wrapper.enable);
|
||||
wrapper.restore = record('restore', wrapper.restore);
|
||||
wrapper.check = record('check', wrapper.check);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
const nodeArg = rigor.gen.int(0, NODES - 1);
|
||||
const relArg = rigor.gen.enum(['owner', 'member_of', 'reads']);
|
||||
const checkRelArg = rigor.gen.enum(['can_read', 'can_access']);
|
||||
|
||||
const result = await rigor.campaign(
|
||||
[rigor.object('graph', makeWrapper, [
|
||||
rigor.method('add', function (src, rel, dst, p) { return this.add(src, rel, dst, p); },
|
||||
rigor.args(nodeArg, relArg, nodeArg, rigor.gen.oneOf([0.1, 0.3, 0.7, 0.9, 0.333]))),
|
||||
rigor.method('remove', function (src, rel, dst) { return this.remove(src, rel, dst); },
|
||||
rigor.args(nodeArg, relArg, nodeArg)),
|
||||
rigor.method('setConfig', function (which) { return this.setConfig(which); },
|
||||
rigor.args(rigor.gen.enum(['direct', 'chain']))),
|
||||
rigor.method('enable', function () { return this.enable(); }),
|
||||
rigor.method('restore', function () { return this.restore(); }),
|
||||
rigor.method('check', function (src, rel, dst) { return this.check(src, rel, dst); },
|
||||
rigor.args(rigor.gen.int(0, 1), checkRelArg, rigor.gen.oneOf([3, 4, 5])))
|
||||
])],
|
||||
rigor.crucible([
|
||||
rigor.after('graph.enable', ({ objects }) => objects.graph.flag === 'enabled'),
|
||||
rigor.after('graph.restore', ({ objects }) => objects.graph.flag === 'restored' && objects.graph.frozen !== null),
|
||||
rigor.before('graph.enable', ({ objects }) => objects.graph.flag !== 'restored'),
|
||||
rigor.between('graph.enable', 'graph.restore', ({ action, objects, actual }) => {
|
||||
if (action !== 'graph.check') return true;
|
||||
return actual.engine === actual.expected && actual.flag === 'enabled';
|
||||
}),
|
||||
rigor.when(({ action, objects }) => action === 'graph.add' || action === 'graph.remove', ({ action, objects, error }) => {
|
||||
const frozen = objects.graph.flag !== 'live';
|
||||
const threw = error !== null;
|
||||
if (frozen) return threw === true;
|
||||
return threw === false;
|
||||
}),
|
||||
rigor.when(({ action }) => action === 'graph.check', ({ objects, actual }) => {
|
||||
if (actual.flag === 'restored') {
|
||||
return Math.abs(actual.engine - actual.expected) <= TOL;
|
||||
}
|
||||
return actual.engine === actual.expected;
|
||||
}),
|
||||
rigor.unless(({ action }) => action === 'graph.check', ({ action, objects, error }) => {
|
||||
if (action === 'graph.add' || action === 'graph.remove') return true;
|
||||
if (action === 'graph.restore') return objects.graph.flag !== 'live' || error !== null;
|
||||
return true;
|
||||
}),
|
||||
rigor.after('graph.setConfig', ({ objects, error }) => error === null || objects.graph.flag === 'restored' ? true : false)
|
||||
])
|
||||
).run({ effort: 400, seed: 'snapshot-lifecycle-protocol', maxTraceLength: 24 });
|
||||
|
||||
describe('Snapshot lifecycle protocol (rigor gated invariants)', () => {
|
||||
it('every random lifecycle sequence honors the protocol', () => {
|
||||
const inv = result.crucibleVerdict;
|
||||
assert.equal(inv.passed, true, [
|
||||
`protocol violated in ${inv.failureCount} cases:`,
|
||||
...result.failures.slice(0, 5).map((f) =>
|
||||
` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
|
||||
)
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user