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:
John Dvorak
2026-07-31 13:44:06 -07:00
commit 717ae1031e
373 changed files with 654131 additions and 0 deletions
@@ -0,0 +1,329 @@
/**
* Graph Engine Property-Based Tests — Authorization & Rule Engine
*
* Tests complex algorithmic P1 items from the ASSESSMENT:
* A10 — Diamond graph intersections (visited backtracking)
* A20 — ChainRule dedup + path-count cap (DoS)
* A21 — minPossibility threshold propagation
* A23 — Reverse chain compilation
* A25 — Per-level short-circuit (never→stop)
*
* These tests MAY fail — that's expected. We're establishing the correct
* behavior baseline before fixing the complex algorithms.
*
* Run: node lib/tests/property-based/authorization-properties.test.js
*/
import { Arbiter } from '../../src/core/Arbiter.js';
import fc from 'fast-check';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeArbiter() {
return new Arbiter({
fastConstructionMode: true,
enableInference: false,
disableCaching: true,
disableChainCaching: true,
disableDirectCaching: true,
});
}
function addNode(a, key, type = 'test') {
a.addNode(key, type);
}
function addRel(a, src, rel, dst, p = 1.0) {
try { a.addRelation(src, rel, dst, { possibility: p }); } catch { /* duplicate */ }
}
function doCheck(a, user, rel, obj) {
return a.check(user, rel, obj, {
partialGraph: null,
includeMeta: false,
explain: false,
fastPath: false,
});
}
// ---------------------------------------------------------------------------
// Arbitraries
// ---------------------------------------------------------------------------
const keyArb = fc.string({ minLength: 2, maxLength: 10 }).map(s => s.replace(/[^a-zA-Z0-9]/g, '_'));
const relArb = fc.constantFrom('owns', 'member', 'viewer', 'editor', 'parent_of');
// ---------------------------------------------------------------------------
// Test 1: Diamond Graph (A10) — two paths should not crash
// ---------------------------------------------------------------------------
function testDiamondGraph() {
console.log('\n=== A10: Diamond Graph Intersections ===');
const arbiter = makeArbiter();
const a = 'u:alice', b = 'u:bob', c = 'u:charlie', d = 'f:doc1';
[a, b, c, d].forEach(k => addNode(arbiter, k, k.startsWith('u:') ? 'user' : 'file'));
// Diamond: a → b → d, a → c → d
addRel(arbiter, a, 'member', b);
addRel(arbiter, b, 'member', d);
addRel(arbiter, a, 'member', c);
addRel(arbiter, c, 'member', d);
arbiter.setRelationConfig('member', { type: 'direct' });
// Two-hop chain over the diamond
arbiter.setRelationConfig('two_hop', {
type: 'chain',
chain: {
steps: [
{ relation: 'member', direction: 'out' },
{ relation: 'member', direction: 'out' },
],
},
});
console.log(' Direct check a→member→a (self):');
try {
const r1 = doCheck(arbiter, a, 'member', a);
console.log(' result:', r1?.possibility, r1?.allowed);
} catch (e) {
console.log(' ERROR:', e.message);
}
console.log(' Two-hop a→two_hop→d (via diamond):');
try {
const r2 = doCheck(arbiter, a, 'two_hop', d);
console.log(' result:', r2?.possibility, r2?.allowed);
} catch (e) {
console.log(' ERROR:', e.message);
}
console.log(' PASS: no crash on diamond graph');
}
// ---------------------------------------------------------------------------
// Test 2: minPossibility Threshold (A21)
// ---------------------------------------------------------------------------
function testMinPossibility() {
console.log('\n=== A21: minPossibility Threshold Propagation ===');
const arbiter = makeArbiter();
addNode(arbiter, 'u:alice', 'user');
addNode(arbiter, 'f:doc1', 'file');
// Direct relation
addRel(arbiter, 'u:alice', 'viewer', 'f:doc1', 0.8);
arbiter.setRelationConfig('viewer', {
type: 'direct',
minPossibility: 0.5,
});
console.log(' Check with possibility=0.8, threshold=0.5:');
const r1 = doCheck(arbiter, 'u:alice', 'viewer', 'f:doc1');
console.log(' result:', r1?.possibility, r1?.allowed);
// Now check with NO relation — should deny
console.log(' Check non-existent relation (should deny):');
const r2 = doCheck(arbiter, 'u:alice', 'viewer', 'f:doc2');
console.log(' result:', r2?.possibility, r2?.allowed);
console.log(' PASS: threshold check completed');
}
// ---------------------------------------------------------------------------
// Test 3: NEVER Short-Circuit (A25)
// ---------------------------------------------------------------------------
function testNeverShortCircuit() {
console.log('\n=== A25: NEVER Per-Level Short-Circuit ===');
const arbiter = makeArbiter();
addNode(arbiter, 'u:alice', 'user');
addNode(arbiter, 'f:doc1', 'file');
addRel(arbiter, 'u:alice', 'viewer', 'f:doc1');
arbiter.setRelationConfig('viewer', { type: 'direct' });
// Test a NEVER-gated rule — should short-circuit evaluation
arbiter.setRelationConfig('never_viewer', {
type: 'defeasible',
mode: 'normal',
never: [{ type: 'direct', relation: 'viewer' }],
strict: [{ type: 'direct', relation: 'viewer' }],
defeasible: [{ type: 'direct', relation: 'viewer' }],
});
console.log(' NEVER rule check (should short-circuit to deny):');
const r1 = doCheck(arbiter, 'u:alice', 'never_viewer', 'f:doc1');
console.log(' result:', r1?.possibility, r1?.allowed);
console.log(' PASS: NEVER short-circuit completed');
}
// ---------------------------------------------------------------------------
// Test 4: ChainRule Path-Count Cap (A20)
// ---------------------------------------------------------------------------
function testChainDedup() {
console.log('\n=== A20: ChainRule Dedup & Path-Count Cap ===');
const arbiter = makeArbiter();
// Build a dense barabasi-albert-like network
const nodes = [];
for (let i = 0; i < 20; i++) {
const key = `n:${i}`;
addNode(arbiter, key, 'test');
nodes.push(key);
}
// Connect many edges
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
if (Math.random() > 0.7) continue;
addRel(arbiter, nodes[i], 'member', nodes[j]);
addRel(arbiter, nodes[j], 'member', nodes[i]);
}
}
arbiter.setRelationConfig('member', { type: 'direct' });
arbiter.setRelationConfig('long_chain', {
type: 'chain',
chain: {
steps: [
{ relation: 'member', direction: 'out' },
{ relation: 'member', direction: 'out' },
{ relation: 'member', direction: 'out' },
],
},
});
console.log(' 3-hop chain on dense graph (20 nodes, many edges):');
try {
const r1 = doCheck(arbiter, nodes[0], 'long_chain', nodes[19]);
console.log(' result:', r1?.possibility, r1?.allowed);
} catch (e) {
console.log(' ERROR:', e.message);
}
console.log(' PASS: chain traversal on dense graph completed');
}
// ---------------------------------------------------------------------------
// Test 5: Reverse Chain Compilation (A23)
// ---------------------------------------------------------------------------
function testReverseChain() {
console.log('\n=== A23: Reverse Chain Compilation ===');
const arbiter = makeArbiter();
addNode(arbiter, 'u:alice', 'user');
addNode(arbiter, 'f:doc1', 'file');
addNode(arbiter, 'f:doc2', 'file');
addRel(arbiter, 'f:doc1', 'viewer', 'u:alice'); // doc1 viewer is alice
addRel(arbiter, 'f:doc1', 'parent', 'f:doc2'); // doc1 parent is doc2
arbiter.setRelationConfig('viewer', { type: 'direct' });
arbiter.setRelationConfig('parent', { type: 'direct' });
// Reverse chain: doc2 ←parent← doc1 ←viewer← u:alice
// Forward: alice → viewer → doc1 → parent → doc2
arbiter.setRelationConfig('inherited_viewer', {
type: 'chain',
chain: {
steps: [
{ relation: 'viewer', direction: 'out' },
{ relation: 'parent', direction: 'out' },
],
},
});
console.log(' Forward chain alice→viewer→doc1→parent→doc2:');
const r1 = doCheck(arbiter, 'u:alice', 'inherited_viewer', 'f:doc2');
console.log(' result:', r1?.possibility, r1?.allowed);
// Also try reverse: use direction 'in'
arbiter.setRelationConfig('reverse_inherited', {
type: 'chain',
reverse: true,
chain: {
steps: [
{ relation: 'viewer', direction: 'in' },
{ relation: 'parent', direction: 'in' },
],
},
});
console.log(' Reverse chain doc2→parent←doc1→viewer←alice:');
const r2 = doCheck(arbiter, 'f:doc2', 'reverse_inherited', 'u:alice');
console.log(' result:', r2?.possibility, r2?.allowed);
console.log(' PASS: reverse chain compilation completed');
}
// ---------------------------------------------------------------------------
// Test 6: Diamond Graph Intersection (A10) — visited backtracking
// ---------------------------------------------------------------------------
function testDiamondIntersection() {
console.log('\n=== A10: Diamond Graph Intersection (visited backtracking) ===');
const arbiter = makeArbiter();
const a = 'u:alice', b = 'u:bob', t = 'g:team', d1 = 'f:doc1', d2 = 'f:doc2';
[a, b, t, d1, d2].forEach(k => addNode(arbiter, k, k.startsWith('u:') ? 'user' : k.startsWith('g:') ? 'group' : 'file'));
// Path 1: alice --friend--> bob --member--> team --access--> doc2
addRel(arbiter, a, 'friend', b);
addRel(arbiter, b, 'member', t);
addRel(arbiter, t, 'access', d2);
// Path 2: alice --owner--> doc1 --parent--> doc2
addRel(arbiter, a, 'owner', d1);
addRel(arbiter, d1, 'parent', d2);
['friend', 'member', 'access', 'owner', 'parent'].forEach(r => {
arbiter.setRelationConfig(r, { type: 'direct' });
});
// Intersection: BOTH paths must succeed (exercises visited backtracking)
arbiter.setRelationConfig('both_paths', { type: 'logical', intersection: { rules: [
{ type: 'chain', chain: { steps: [
{ relation: 'friend', direction: 'out' }, { relation: 'member', direction: 'out' }, { relation: 'access', direction: 'out' }
]}},
{ type: 'chain', chain: { steps: [
{ relation: 'owner', direction: 'out' }, { relation: 'parent', direction: 'out' }
]}}
]}});
const r1 = doCheck(arbiter, a, 'both_paths', d2);
console.log(' Diamond intersection:', r1?.possibility, r1?.reason);
if (r1?.possibility > 0) {
console.log(' PASS: both paths correctly intersected');
} else if (r1?.reason === 'cycle') {
console.log(' FAIL: visited backtracking bug — second path saw cycle from first');
} else {
console.log(' UNEXPECTED:', r1?.reason);
}
}
// ---------------------------------------------------------------------------
// Run all
// ---------------------------------------------------------------------------
try { testDiamondGraph(); } catch (e) { console.error('DIAMOND FAIL:', e.message); }
try { testMinPossibility(); } catch (e) { console.error('THRESHOLD FAIL:', e.message); }
try { testNeverShortCircuit(); } catch (e) { console.error('NEVER FAIL:', e.message); }
try { testChainDedup(); } catch (e) { console.error('CHAIN FAIL:', e.message); }
try { testReverseChain(); } catch (e) { console.error('REVERSE FAIL:', e.message); }
try { testDiamondIntersection(); } catch (e) { console.error('DIAMOND INTERSECTION FAIL:', e.message); }
console.log('\n=== All tests executed ===');
@@ -0,0 +1,107 @@
/**
* SnapshotBinary round-trip tests.
*
* Verifies:
* - serialize/deserialize preserves nodes and relations
* - Deserialized snapshots are marked _snapshotReadOnly
* - UTF-8 keys survive round-trip
* - Relation configs survive round-trip
*
* Run: node --test --test-force-exit lib/tests/property-based/snapshot-binary.test.js
*/
import { Arbiter } from '../../src/core/Arbiter.js';
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
function createArbiterFactory() {
return () => new Arbiter({ fastConstructionMode: true, enableInference: false });
}
async function loadSnapshotModule() {
return import('../../src/core/SnapshotBinary.js');
}
describe('SnapshotBinary — Round-Trip', () => {
it('serialize and deserialize preserves nodes and relations', async () => {
const { serializeArbiterSnapshot, deserializeArbiterSnapshot } = await loadSnapshotModule();
const original = new Arbiter({ fastConstructionMode: true, enableInference: false });
original.addNode('user:alice', 'user');
original.addNode('project:secret', 'project');
original.addRelation('user:alice', 'can_read', 'project:secret', { possibility: 0.9, value: 100 });
original.setRelationConfig('can_read', { type: 'direct' });
original.enableCondensedSnapshot();
const buffer = serializeArbiterSnapshot(original);
assert.ok(buffer instanceof ArrayBuffer);
assert.ok(buffer.byteLength > 0);
const restored = deserializeArbiterSnapshot(buffer, createArbiterFactory());
assert.ok(restored.nodeIdByKey.has('user:alice'));
assert.ok(restored.nodeIdByKey.has('project:secret'));
const nodes = [...restored.nodes.values()].filter(Boolean);
assert.ok(nodes.length >= 2);
});
it('deserialized snapshot is marked read-only', async () => {
const { serializeArbiterSnapshot, deserializeArbiterSnapshot } = await loadSnapshotModule();
const original = new Arbiter({ fastConstructionMode: true, enableInference: false });
original.addNode('user:alice', 'user');
original.addNode('project:secret', 'project');
original.addRelation('user:alice', 'can_read', 'project:secret', { possibility: 0.9 });
original.enableCondensedSnapshot();
const buffer = serializeArbiterSnapshot(original);
const restored = deserializeArbiterSnapshot(buffer, createArbiterFactory());
assert.strictEqual(restored._snapshotReadOnly, true);
assert.strictEqual(restored.snapshotEnabled, true);
assert.ok(restored.snapshotGraph);
});
it('UTF-8 keys survive round-trip', async () => {
const { serializeArbiterSnapshot, deserializeArbiterSnapshot } = await loadSnapshotModule();
const original = new Arbiter({ fastConstructionMode: true, enableInference: false });
original.addNode('user:alice', 'user');
original.addNode('project:résumé', 'project');
original.addRelation('user:alice', 'can_read', 'project:résumé', { possibility: 0.9 });
original.setRelationConfig('can_read', { type: 'direct' });
original.enableCondensedSnapshot();
const buffer = serializeArbiterSnapshot(original);
const restored = deserializeArbiterSnapshot(buffer, createArbiterFactory());
assert.ok(restored.nodeIdByKey.has('project:résumé'));
assert.strictEqual(restored.relationConfigs.has('can_read'), true);
});
it('relation configs survive round-trip', async () => {
const { serializeArbiterSnapshot, deserializeArbiterSnapshot } = await loadSnapshotModule();
const original = new Arbiter({ fastConstructionMode: true, enableInference: false });
original.addNode('user:alice', 'user');
original.addNode('project:secret', 'project');
original.addRelation('user:alice', 'can_access', 'project:secret', {
possibility: 0.8,
value: 42,
reliability: 0.95
});
original.setRelationConfig('can_access', { type: 'chain', steps: [{ relation: 'parent', direction: 'out' }] });
original.setRelationConfig('can_read', { type: 'direct', minPossibility: 0.5 });
original.enableCondensedSnapshot();
const buffer = serializeArbiterSnapshot(original);
const restored = deserializeArbiterSnapshot(buffer, createArbiterFactory());
const accessConfig = restored.relationConfigs.get('can_access');
assert.strictEqual(accessConfig && accessConfig.type, 'chain');
assert.deepStrictEqual(accessConfig && accessConfig.steps, [{ relation: 'parent', direction: 'out' }]);
const readConfig = restored.relationConfigs.get('can_read');
assert.strictEqual(readConfig && readConfig.type, 'direct');
assert.strictEqual(readConfig && readConfig.minPossibility, 0.5);
});
});
@@ -0,0 +1,70 @@
/**
* Snapshot read-only guard tests — verifies that public write paths
* throw when _snapshotReadOnly is true.
*
* After the P1.4 fix, the following methods must reject writes:
* - addRelation (public, existing guard)
* - removeRelation (public, existing guard)
*
* Run: node --test --test-force-exit lib/tests/property-based/snapshot-read-only.test.js
*/
import { Arbiter } from '../../src/core/Arbiter.js';
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
describe('Snapshot Read-Only Guards', () => {
let arbiter;
beforeEach(() => {
arbiter = new Arbiter({ fastConstructionMode: true, enableInference: false });
arbiter.addNode('user:alice', 'user');
arbiter.addNode('project:secret', 'project');
arbiter._snapshotReadOnly = true;
});
it('addRelation throws when read-only', () => {
assert.throws(
() => arbiter.addRelation('user:alice', 'can_read', 'project:secret', 0.9),
/Cannot add relation while in snapshot read-only mode/
);
});
it('removeRelation throws when read-only', () => {
arbiter._snapshotReadOnly = false;
arbiter.addRelation('user:alice', 'can_read', 'project:secret', 0.9);
arbiter._snapshotReadOnly = true;
assert.throws(
() => arbiter.removeRelation('user:alice', 'can_read', 'project:secret'),
/Cannot remove relation while in snapshot read-only mode/
);
});
it('writes succeed when not in read-only mode', () => {
arbiter._snapshotReadOnly = false;
arbiter.addRelation('user:alice', 'can_read', 'project:secret', 0.9);
assert.strictEqual(arbiter.relations.length, 1);
arbiter.addRelation('user:alice', 'can_write', 'project:secret', 0.5);
assert.strictEqual(arbiter.relations.length, 2);
});
it('deserialized snapshot rejects writes', () => {
arbiter._snapshotReadOnly = false;
arbiter.addRelation('user:alice', 'can_read', 'project:secret', 0.9);
arbiter._snapshotReadOnly = true;
assert.throws(
() => arbiter.addRelation('user:alice', 'can_write', 'project:secret', 0.5),
/Cannot add relation while in snapshot read-only mode/
);
});
it('fresh arbiter in read-only mode rejects writes immediately', () => {
assert.throws(
() => arbiter.addRelation('user:alice', 'can_read', 'project:secret', 0.5),
/Cannot add relation while in snapshot read-only mode/
);
});
});