4fd4e20bd0
Systemic reliability gap found by the probe sweep: the compiled evaluation paths never emitted the reliability the engine computes. - Compiled _evaluateDirect omitted the relation's reliability, and the chain/multi_hop rules hardcoded reliability: 1.0 — so check() results reported 1.0 for any rule whose decision came through a chain, multi_hop, union, intersection, exclusion, or defeasible combination. - The chain and multi_hop traversals now track per-path reliability (product of edge reliabilities) and report the winning path's value; the compiled and fallback logical operators (union/intersection/exclusion, direct_list fast path, early exits) report the selected child's reliability (max/min child or OWA trace index; exclusion multiplies both legs), and normal-mode defeasible combines base x requires x defeater reliabilities. - The checker's logical fast path dropped collectedValues from union/ intersection/exclusion results; it now passes them through. - MultiHopRule.valueManager was read off relationManager where the real arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule with a value-carrying edge crashed the evaluation (error result, silent denial). Now resolved at the arbiter level with a relationManager fallback for stubs. Campaign pins: reliability per kind (chain/multi_hop product, union/intersection selected child, exclusion/defeasible product), and multi_hop value collection through persistent and partial contexts.
460 lines
19 KiB
JavaScript
460 lines
19 KiB
JavaScript
/**
|
|
* rigor/direct-rule.test.js — js-rigor property tests for DirectRule.
|
|
*
|
|
* DirectRule is the simplest authorization rule: it asks the relation manager
|
|
* for a direct (src, rel, dst) tuple and returns a standardized result with
|
|
* raw possibility values. Properties verified:
|
|
*
|
|
* - No relation → possibility=0, possibility_allow=0, possibility_deny=0,
|
|
* meta.ruleType='direct', reason='no_relation'
|
|
* - Relation present with strength s → possibility=s, possibility_allow=s,
|
|
* possibility_deny=0, reason='exists'
|
|
* - reverse=true routes the lookup to (objectId, rel, userId)
|
|
* - fastPath with minPossibility threshold sets meta.earlyExit on hit
|
|
* - collectValues=false suppresses collectedValues
|
|
* - relation field precedence: rule.relation > rule.rel > rule.label >
|
|
* rule.name > currentRelation
|
|
* - possibility ∈ [0,1] is preserved through the result
|
|
* - result shape is stable (always has the same keys)
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { DirectRule } from '../../src/authorization/rules/DirectRule.js';
|
|
|
|
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
|
|
|
/**
|
|
* Build an arbiter stub whose relationManager.getDirectRelation returns
|
|
* the value from a static relation table. Records calls for reverse/
|
|
* non-reverse direction assertions.
|
|
*/
|
|
function makeArbiter(relations = {}) {
|
|
const calls = [];
|
|
const arbiter = {
|
|
relationManager: {
|
|
getDirectRelation(srcId, rel, dstId, options) {
|
|
calls.push({ srcId, rel, dstId, reverse: options?.reverse });
|
|
const key = `${srcId}|${rel}|${dstId}`;
|
|
return relations[key] ?? null;
|
|
}
|
|
}
|
|
};
|
|
return { arbiter, calls };
|
|
}
|
|
|
|
describe('DirectRule evaluation (rigor)', () => {
|
|
it('returns possibility=0 with reason=no_relation when no direct relation exists', async () => {
|
|
async function check(userId, objectId, relName) {
|
|
const { arbiter } = makeArbiter({});
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: relName },
|
|
new Set(),
|
|
relName,
|
|
{}
|
|
);
|
|
if (result.possibility !== 0) throw new Error(`possibility=${result.possibility}, expected 0`);
|
|
if (result.possibility_allow !== 0) throw new Error(`possibility_allow=${result.possibility_allow}, expected 0`);
|
|
if (result.possibility_deny !== 0) throw new Error(`possibility_deny=${result.possibility_deny}, expected 0`);
|
|
// DirectRule returns reason under meta.reason (AuthorizationChecker.check normalizes
|
|
// it to top-level result.reason); assert on the direct contract.
|
|
if (!result.meta || result.meta.ruleType !== 'direct') {
|
|
throw new Error(`meta.ruleType=${result.meta?.ruleType}, expected 'direct'`);
|
|
}
|
|
if (result.meta.reason !== 'no_relation') {
|
|
throw new Error(`meta.reason=${result.meta.reason}, expected 'no_relation'`);
|
|
}
|
|
if (result.collectedValues.length !== 0) {
|
|
throw new Error(`expected no collected values, got ${result.collectedValues.length}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('no-relation-fallback', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-relation-fallback');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `no-relation contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('returns relation strength as possibility/possibility_allow when present', async () => {
|
|
async function check(userId, objectId, relName, strength) {
|
|
const key = `${userId}|${relName}|${objectId}`;
|
|
const { arbiter } = makeArbiter({ [key]: { possibility: strength } });
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: relName },
|
|
new Set(),
|
|
relName,
|
|
{}
|
|
);
|
|
if (result.possibility !== strength) {
|
|
throw new Error(`possibility=${result.possibility}, expected ${strength}`);
|
|
}
|
|
if (result.possibility_allow !== strength) {
|
|
throw new Error(`possibility_allow=${result.possibility_allow}, expected ${strength}`);
|
|
}
|
|
if (result.possibility_deny !== 0) {
|
|
throw new Error(`possibility_deny=${result.possibility_deny}, expected 0 (DirectRule never denies)`);
|
|
}
|
|
// DirectRule returns reason under meta.reason (AuthorizationChecker.check normalizes
|
|
// it to top-level result.reason); assert on the direct contract.
|
|
if (!result.meta || result.meta.ruleType !== 'direct') {
|
|
throw new Error(`meta.ruleType=${result.meta?.ruleType}, expected 'direct'`);
|
|
}
|
|
if (result.meta.reason !== 'relation_exists') {
|
|
throw new Error(`meta.reason=${result.meta.reason}, expected 'relation_exists'`);
|
|
}
|
|
// result.possibility should be in [0,1] (it is, by construction)
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.float({ min: 0, max: 1 })
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('relation-strength-preserved', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'relation-strength-preserved');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `relation-strength contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('reverse=true routes the lookup through (objectId, rel, userId)', async () => {
|
|
async function check(userId, objectId, relName) {
|
|
const key = `${objectId}|${relName}|${userId}`;
|
|
const { arbiter, calls } = makeArbiter({ [key]: { possibility: 0.5 } });
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: relName, reverse: true },
|
|
new Set(),
|
|
relName,
|
|
{}
|
|
);
|
|
// First call should have been (userId, rel, objectId) IF reverse=false;
|
|
// since reverse=true, the call should be (objectId, rel, userId)
|
|
const last = calls[calls.length - 1];
|
|
if (last.srcId !== objectId || last.dstId !== userId) {
|
|
throw new Error(`expected lookup (objectId, rel, userId)=(${objectId}, ${relName}, ${userId}), got (${last.srcId}, ${last.rel}, ${last.dstId})`);
|
|
}
|
|
// And the result should reflect the found relation
|
|
if (result.possibility !== 0.5) {
|
|
throw new Error(`reverse lookup should find relation strength 0.5, got ${result.possibility}`);
|
|
}
|
|
if (result.meta.reverse !== true) {
|
|
throw new Error(`meta.reverse=${result.meta.reverse}, expected true`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('reverse-routing', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'reverse-routing');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `reverse-routing contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('fastPath with minPossibility threshold sets meta.earlyExit on hit', async () => {
|
|
async function check(userId, objectId, relName, strength, threshold) {
|
|
const key = `${userId}|${relName}|${objectId}`;
|
|
const { arbiter } = makeArbiter({ [key]: { possibility: strength } });
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: relName },
|
|
new Set(),
|
|
relName,
|
|
{ fastPath: true, minPossibility: threshold }
|
|
);
|
|
// If strength >= threshold, earlyExit should be set
|
|
if (strength >= threshold) {
|
|
if (!result.meta?.earlyExit) {
|
|
throw new Error(`expected meta.earlyExit=true when strength=${strength} >= threshold=${threshold}, got ${JSON.stringify(result.meta)}`);
|
|
}
|
|
if (result.meta.earlyExitReason !== 'strength_threshold_met') {
|
|
throw new Error(`expected earlyExitReason='strength_threshold_met', got '${result.meta.earlyExitReason}'`);
|
|
}
|
|
} else {
|
|
if (result.meta?.earlyExit) {
|
|
throw new Error(`did not expect earlyExit when strength=${strength} < threshold=${threshold}`);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.float({ min: 0, max: 1 }),
|
|
rigor.gen.float({ min: 0, max: 1 })
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('fastPath-early-exit', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'fastPath-early-exit');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `fastPath early-exit violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('collectValues=false suppresses collected values even when relation has them', async () => {
|
|
async function check(userId, objectId, relName) {
|
|
const key = `${userId}|${relName}|${objectId}`;
|
|
const { arbiter } = makeArbiter({ [key]: { possibility: 0.8, value: 42 } });
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: relName },
|
|
new Set(),
|
|
relName,
|
|
{ collectValues: false }
|
|
);
|
|
if (result.collectedValues.length !== 0) {
|
|
throw new Error(`expected no collected values when collectValues=false, got ${result.collectedValues.length}`);
|
|
}
|
|
// possibility is independent of collectValues
|
|
if (result.possibility !== 0.8) {
|
|
throw new Error(`possibility=${result.possibility}, expected 0.8`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('collectValues-disabled', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'collectValues-disabled');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `collectValues=false violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('collectValues=true (default) includes collected value with full metadata', async () => {
|
|
async function check(userId, objectId, relName, value) {
|
|
const key = `${userId}|${relName}|${objectId}`;
|
|
const { arbiter } = makeArbiter({ [key]: { possibility: 0.7, value, changed_last_at: 5000, source: 'persistent' } });
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: relName },
|
|
new Set(),
|
|
relName,
|
|
{}
|
|
);
|
|
if (result.collectedValues.length !== 1) {
|
|
throw new Error(`expected 1 collected value, got ${result.collectedValues.length}`);
|
|
}
|
|
const cv = result.collectedValues[0];
|
|
if (cv.value !== value) throw new Error(`cv.value=${cv.value}, expected ${value}`);
|
|
if (cv.possibility !== 0.7) throw new Error(`cv.possibility=${cv.possibility}, expected 0.7`);
|
|
if (!Array.isArray(cv.path) || cv.path.length !== 2) {
|
|
throw new Error(`cv.path malformed: ${JSON.stringify(cv.path)}`);
|
|
}
|
|
if (cv.path[0] !== `user:${userId}` || cv.path[1] !== `doc:${objectId}`) {
|
|
throw new Error(`cv.path=${JSON.stringify(cv.path)}, expected [user:${userId}, doc:${objectId}]`);
|
|
}
|
|
if (cv.source.relation !== relName) {
|
|
throw new Error(`cv.source.relation=${cv.source.relation}, expected ${relName}`);
|
|
}
|
|
if (cv.metadata.timestamp !== 5000) {
|
|
throw new Error(`cv.metadata.timestamp=${cv.metadata.timestamp}, expected 5000`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.float({ min: 0, max: 1000 })
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('collectValues-default', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'collectValues-default');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `collectValues default violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('relation field precedence: rule.relation beats rule.rel, label, name, currentRelation', async () => {
|
|
// To verify precedence, we put a relation under each candidate name and assert
|
|
// that DirectRule picks the highest-precedence one. When two candidates share
|
|
// a name, the oracle can't distinguish them, so we skip those cases.
|
|
async function check(userId, objectId, fromRule, fromRel, fromLabel, fromName, fromCurrent) {
|
|
// Skip cases where any pair of candidates share a name — the test can't
|
|
// distinguish precedence in that scenario
|
|
const names = { rule: fromRule, rel: fromRel, label: fromLabel, name: fromName, current: fromCurrent };
|
|
if (new Set(Object.values(names)).size !== 5) return null;
|
|
|
|
// Build a relations table that has the relation under EVERY candidate name.
|
|
const { arbiter, calls } = makeArbiter({
|
|
[`${userId}|${fromRule}|${objectId}`]: { possibility: 0.1 },
|
|
[`${userId}|${fromRel}|${objectId}`]: { possibility: 0.2 },
|
|
[`${userId}|${fromLabel}|${objectId}`]: { possibility: 0.3 },
|
|
[`${userId}|${fromName}|${objectId}`]: { possibility: 0.4 },
|
|
[`${userId}|${fromCurrent}|${objectId}`]: { possibility: 0.5 }
|
|
});
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: fromRule, rel: fromRel, label: fromLabel, name: fromName },
|
|
new Set(),
|
|
fromCurrent,
|
|
{}
|
|
);
|
|
// DirectRule should pick fromRule (highest precedence)
|
|
if (result.possibility !== 0.1) {
|
|
throw new Error(`expected possibility=0.1 (from rule.relation=${fromRule}), got ${result.possibility}`);
|
|
}
|
|
// The relationManager call should have been made with fromRule
|
|
if (calls.length !== 1 || calls[0].rel !== fromRule) {
|
|
throw new Error(`expected single call with rel=${fromRule}, got ${JSON.stringify(calls)}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.enum(RELATIONS)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('relation-precedence', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'relation-precedence');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `relation-precedence contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('result shape is stable: all expected keys always present', async () => {
|
|
async function check(userId, objectId, relName, hasRelation) {
|
|
const key = `${userId}|${relName}|${objectId}`;
|
|
const relations = hasRelation ? { [key]: { possibility: 0.6, value: 99 } } : {};
|
|
const { arbiter } = makeArbiter(relations);
|
|
const rule = new DirectRule(arbiter);
|
|
const result = rule.evaluate(
|
|
userId, `user:${userId}`,
|
|
objectId, `doc:${objectId}`,
|
|
{ type: 'direct', relation: relName },
|
|
new Set(),
|
|
relName,
|
|
{}
|
|
);
|
|
// _createStandardResult guarantees these keys
|
|
const expectedKeys = ['possibility', 'reliability', 'possibility_allow', 'possibility_deny', 'collectedValues', 'meta', 'meta_allow', 'meta_deny', 'remediation', 'reason'];
|
|
for (const k of expectedKeys) {
|
|
if (!(k in result)) {
|
|
throw new Error(`result missing key '${k}' (full result: ${JSON.stringify(result)})`);
|
|
}
|
|
}
|
|
// possibility_allow and possibility must equal each other in DirectRule
|
|
if (result.possibility_allow !== result.possibility) {
|
|
throw new Error(`possibility_allow (${result.possibility_allow}) !== possibility (${result.possibility})`);
|
|
}
|
|
// possibility_deny is always 0 in DirectRule
|
|
if (result.possibility_deny !== 0) {
|
|
throw new Error(`possibility_deny=${result.possibility_deny}, expected 0 (DirectRule never denies)`);
|
|
}
|
|
// reliability defaults to 1.0
|
|
if (result.reliability !== 1.0) {
|
|
throw new Error(`reliability=${result.reliability}, expected 1.0`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.int(0, 1000),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.boolean()
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('result-shape-stable', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'result-shape-stable');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `result-shape contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|