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,216 @@
|
||||
/**
|
||||
* rigor/chain-rule.test.js — js-rigor property tests for ChainRule.
|
||||
*
|
||||
* ChainRule follows a chain of relations and collects values along the path.
|
||||
* Properties verified:
|
||||
*
|
||||
* - Empty steps → possibility=0, reason='no_chain_steps_defined'
|
||||
* - Empty graph (no relations) → possibility=0
|
||||
* - 1-step chain with matching relation → possibility > 0
|
||||
* - 2-step chain through intermediate node → possibility > 0
|
||||
* - result.possibility ∈ [0, 1]
|
||||
* - bypassPLTC: true skips reachability check
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { ChainRule } from '../../src/authorization/rules/ChainRule.js';
|
||||
|
||||
describe('ChainRule evaluation (rigor)', () => {
|
||||
it('empty steps → possibility=0, reason=no_chain_steps_defined', async () => {
|
||||
async function check() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected 0 for empty steps, got ${result.possibility}`);
|
||||
}
|
||||
if (result.reason !== 'no_chain_steps_defined') {
|
||||
throw new Error(`expected reason='no_chain_steps_defined', got '${result.reason}'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('empty-steps', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'empty-steps');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `empty-steps contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] with various chain configurations', async () => {
|
||||
async function check(strength) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
throw new Error(`possibility=${result.possibility} outside [0,1]`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.float({ min: 0, max: 1 }))
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('no matching path in graph → possibility=0', async () => {
|
||||
async function check() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
// No relations at all
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected 0 with no relations, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-path', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-path');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `no-path contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('1-step chain with matching relation → possibility > 0', async () => {
|
||||
async function check(strength) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility <= 0) {
|
||||
throw new Error(`expected possibility>0 with path, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.float({ min: 0.01, max: 1 }))
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('one-step-pos', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'one-step-pos');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `one-step-pos contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('2-step chain through intermediate → possibility > 0 (when both legs exist)', async () => {
|
||||
async function check(strength1, strength2) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('team:eng', 'team');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'member_of', 'team:eng', { possibility: strength1 });
|
||||
arbiter.addRelation('team:eng', 'has_access', 'doc:secret', { possibility: strength2 });
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'has_access', direction: 'out' }
|
||||
] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
// Path exists, so possibility should be > 0
|
||||
if (result.possibility <= 0) {
|
||||
throw new Error(`expected possibility>0 with 2-step path, got ${result.possibility}`);
|
||||
}
|
||||
// And it should be bounded by min(strength1, strength2) along the chain
|
||||
if (result.possibility > Math.min(strength1, strength2) + 0.01) {
|
||||
throw new Error(`possibility=${result.possibility} exceeds chain min(${strength1}, ${strength2})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('two-step-chain', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'two-step-chain');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `two-step-chain contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user