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,352 @@
|
||||
/**
|
||||
* rigor/tuple-to-userset-rule.test.js — js-rigor property tests for TupleToUsersetRule.
|
||||
*
|
||||
* TupleToUsersetRule grants access via two-step pattern: object has tupleset
|
||||
* relation to an intermediate, user has computed relation to that intermediate.
|
||||
* Properties verified in the 'direct join' mode (computedRelation.type='direct'):
|
||||
*
|
||||
* - No tuples → possibility=0, reason='no_valid_intermediate_paths'
|
||||
* - One tuple + one matching direct edge → possibility = min(tuplePoss, edgePoss)
|
||||
* - Multiple tuples → max fused (default OWA weights [1,0,0,...])
|
||||
* - Cycle detection → reason='cycle', possibility=0
|
||||
* - reverse=true routes the lookup via the user side, not the object side
|
||||
* - earlyExitThreshold triggers early return when a path exceeds it
|
||||
* - result.possibility ∈ [0, 1] always
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { TupleToUsersetRule } from '../../src/authorization/rules/TupleToUsersetRule.js';
|
||||
|
||||
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
||||
|
||||
/**
|
||||
* Build an arbiter stub. relationManager.getRelationsFromSrc/getRelationsToDst
|
||||
* return relations from a static table. relationConfigs.get returns
|
||||
* { type: 'direct' } for the computedRelation to enable useDirectJoin branch.
|
||||
* Also exposes a no-op authChecker.check for any fallback path.
|
||||
*/
|
||||
function makeArbiter({ tuplesetRels, directEdges, keyMap, computedRelation = 'member' }) {
|
||||
const relationConfigs = new Map([
|
||||
['direct', { type: 'direct' }],
|
||||
[computedRelation, { type: 'direct' }] // enables useDirectJoin for this computed relation
|
||||
]);
|
||||
return {
|
||||
relationManager: {
|
||||
shouldUseRelationGraphTraversal() { return false; },
|
||||
getDirectRelation(srcId, rel, dstId) {
|
||||
return directEdges.get(`${srcId}|${rel}|${dstId}`) ?? null;
|
||||
},
|
||||
getRelationsFromSrc(srcId, relName) {
|
||||
return tuplesetRels.get(`${srcId}|${relName}`) ?? [];
|
||||
},
|
||||
getRelationsToDst(dstId, relName) {
|
||||
return tuplesetRels.get(`_toDst|${dstId}|${relName}`) ?? [];
|
||||
}
|
||||
},
|
||||
relationConfigs,
|
||||
keyManager: {
|
||||
_getRelationId(name) {
|
||||
return `__relId:${name}`;
|
||||
}
|
||||
},
|
||||
resolveKey(nodeId) { return keyMap.get(nodeId) ?? null; },
|
||||
resolveNodeId(key) { return keyMap.get(`_rev:${key}`) ?? null; },
|
||||
// authChecker.check is unreachable in useDirectJoin mode, but stub it for safety
|
||||
authChecker: { check() { return { possibility: 0, reliability: 1.0, reason: 'no_authChecker' }; } }
|
||||
};
|
||||
}
|
||||
|
||||
describe('TupleToUsersetRule evaluation (rigor)', () => {
|
||||
it('no tuples → possibility=0, reason=no_valid_intermediate_paths', async () => {
|
||||
async function check(userKey, objectKey) {
|
||||
const arbiter = makeArbiter({
|
||||
tuplesetRels: new Map(),
|
||||
directEdges: new Map(),
|
||||
keyMap: new Map([[1, 'u1'], [99, 'o99']])
|
||||
});
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, userKey, 99, objectKey,
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`possibility=${result.possibility}, expected 0`);
|
||||
}
|
||||
if (result.reason !== 'no_valid_intermediate_paths') {
|
||||
throw new Error(`reason=${result.reason}, expected 'no_valid_intermediate_paths'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.string(1, 30)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-tuples', ({ 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-tuples');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `no-tuples contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('one tuple + matching direct edge → possibility = min(tuplePoss, edgePoss)', async () => {
|
||||
async function check(tuplePoss, edgePoss) {
|
||||
// Object 99 has tuple owner → 50. User 1 has direct edge member → 50.
|
||||
const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]);
|
||||
// For useDirectJoin mode, computedEdges come from getRelationsFromSrc(userId, computedRelation).
|
||||
// The direct edge between user and intermediate must be present there, not just in directEdges.
|
||||
const directEdges = new Map();
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]],
|
||||
[`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: edgePoss }]]
|
||||
]);
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
const expected = Math.min(tuplePoss, edgePoss);
|
||||
if (result.possibility !== expected) {
|
||||
throw new Error(`possibility=${result.possibility}, expected ${expected} (min of ${tuplePoss}, ${edgePoss})`);
|
||||
}
|
||||
if (result.reason !== 'tuple_to_userset_found') {
|
||||
throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`);
|
||||
}
|
||||
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('min-fusion', ({ 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 === 'min-fusion');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `min-fusion contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('multiple tuples → fused via max (default OWA weights)', async () => {
|
||||
async function check(tuplePoss1, tuplePoss2, edgePoss1, edgePoss2) {
|
||||
// Object 99 has two tuples: owner → 50, owner → 51. User has matching edges.
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [
|
||||
{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss1 },
|
||||
{ src: 99, rel: 'owner', dst: 51, possibility: tuplePoss2 }
|
||||
]],
|
||||
[`1|member`, [
|
||||
{ src: 1, rel: 'member', dst: 50, possibility: edgePoss1 },
|
||||
{ src: 1, rel: 'member', dst: 51, possibility: edgePoss2 }
|
||||
]]
|
||||
]);
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
const path1 = Math.min(tuplePoss1, edgePoss1);
|
||||
const path2 = Math.min(tuplePoss2, edgePoss2);
|
||||
const expected = Math.max(path1, path2);
|
||||
if (result.possibility !== expected) {
|
||||
throw new Error(`possibility=${result.possibility}, expected ${expected} (max of ${path1}, ${path2})`);
|
||||
}
|
||||
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.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-tuple-max', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-tuple-max');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `multi-tuple-max contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] always', async () => {
|
||||
async function check(tuplePoss, edgePoss) {
|
||||
const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]);
|
||||
const directEdges = new Map([[`1|member|50`, { possibility: edgePoss }]]);
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: 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.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('early exit: high-strength path triggers early_return reason', async () => {
|
||||
async function check() {
|
||||
// 3 tuples, one with very high strength (triggers early exit at threshold=0.95)
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [
|
||||
{ src: 99, rel: 'owner', dst: 50, possibility: 0.99 }, // high — should trigger
|
||||
{ src: 99, rel: 'owner', dst: 51, possibility: 0.5 },
|
||||
{ src: 99, rel: 'owner', dst: 52, possibility: 0.3 }
|
||||
]],
|
||||
[`1|member`, [
|
||||
{ src: 1, rel: 'member', dst: 50, possibility: 1.0 }, // combined = min(0.99, 1.0) = 0.99
|
||||
{ src: 1, rel: 'member', dst: 51, possibility: 0.4 },
|
||||
{ src: 1, rel: 'member', dst: 52, possibility: 0.2 }
|
||||
]]
|
||||
]);
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51'], [52, 'i52']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
// The early exit returns possibility=0.99 (the combined path that triggered it)
|
||||
if (result.possibility < 0.95) {
|
||||
throw new Error(`early exit should return high-strength path; got ${result.possibility}`);
|
||||
}
|
||||
// The reason stays 'tuple_to_userset_found' for a single-path early exit —
|
||||
// the 'early_exit_direct_path' string is only set in evaluationMeta.evaluationType.
|
||||
if (result.reason !== 'tuple_to_userset_found') {
|
||||
throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`);
|
||||
}
|
||||
// But the meta.evaluation.evaluationType should signal early_exit_direct_path
|
||||
const evalType = result.meta?.evaluation?.evaluationType;
|
||||
if (evalType !== 'early_exit_direct_path') {
|
||||
throw new Error(`meta.evaluation.evaluationType=${evalType}, expected 'early_exit_direct_path'`);
|
||||
}
|
||||
// And the performance stats should show earlyExits++
|
||||
if (rule.performanceStats.earlyExits !== 1) {
|
||||
throw new Error(`rule.performanceStats.earlyExits=${rule.performanceStats.earlyExits}, expected 1`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('early-exit', ({ 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 === 'early-exit');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `early-exit violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('cycle detection: visited set causes reason=cycle', async () => {
|
||||
async function check() {
|
||||
// For cycle detection to fire, we need joinMode === 'computed', which
|
||||
// requires computedEdges.length < tuples.length. With 1 tuple and 1
|
||||
// computed edge, computedEdges.length === 1, tuples.length === 1, so
|
||||
// joinMode === 'tuples' and the cycle check on line 275-283 runs.
|
||||
// Set up: 1 tuple from object → 50, 1 computed edge user → 50, both
|
||||
// populated. The visited set contains the cycle key, so when the
|
||||
// tuples-loop processes intermediate 50, it sees the cycle.
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: 0.9 }]],
|
||||
[`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: 1.0 }]]
|
||||
]);
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
// Pre-populate visited with the cycle key — computedRelationId is "__relId:member"
|
||||
const visited = new Set([`1|__relId:member|50`]);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
visited,
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
// Either reason='cycle' directly, OR the path possibility becomes 0 because
|
||||
// the cycle-detected edge yields res.possibility=0.
|
||||
// Looking at production: when res.reason='cycle', reasons array contains 'cycle',
|
||||
// and at end of _buildFinalResult: reason = reasons.includes('cycle') ? 'cycle' : ...
|
||||
// So result.reason SHOULD be 'cycle'.
|
||||
if (result.reason !== 'cycle') {
|
||||
throw new Error(`cycle reason=${result.reason}, expected 'cycle'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('cycle-detection', ({ 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 === 'cycle-detection');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `cycle-detection violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user