ed34df4474
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
position is now EXPANDED from the current node — the rule's base edges'
destinations, filtered by its defeaters/requirements — and traversal
continues from each discovered node. Adds _expandRuleFromSrc / direct /
logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
entity IS the object parameter (trusted(other) inside peer_trusted(user,
other)); previously only subject-var unary calls (_subjectAsObject) were
handled, so object-var unary defeaters never fired.
Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
DecisionCache rule cache now stamp entries with the graph version and treat
any mismatch as a miss — graph mutations can no longer serve stale
chain/authorization results.
Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
JSON.stringify — no string allocation or serialization on the chain-cache
hot path. Composite keys stay structured strings because the direct-check
cache pattern-invalidates by relation ID.
Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
!error && !errorMessage) never saw fn throws — vacuous. Migrated to
({ actual }) => actual !== undefined, which fails on any thrown violation
while passing legitimate null-skips. The migration immediately surfaced
two latent bugs, now fixed:
* node-manager/graph-indices skip paths returned bare undefined (falsy
sentinel) — return { skipped: true }.
* complex-graph-values-crucible expiry section rewrote values equal to the
mutation loop's last write; the engine (by design) keeps the old
timestamp on same-value rewrites so the pre-expiry grant never
materialized. Now writes guaranteed-different values.
352 lines
14 KiB
JavaScript
352 lines
14 KiB
JavaScript
/**
|
|
* rigor/parent-rule.test.js — js-rigor property tests for ParentRule.
|
|
*
|
|
* ParentRule grants access via parent-child relationships. The user is checked
|
|
* against the target relation on each parent of the object, then OWA-fused.
|
|
* Properties verified:
|
|
*
|
|
* - No parents → possibility=0, reason='no_parent_relationship_path_above_threshold'
|
|
* - One parent with direct access at strength s → possibility=s (or 0 if below threshold)
|
|
* - Multiple parents → fused via OWA aggregator (default 'max')
|
|
* - Cycle (parentKey === userKey) → possibility=0, reason='cycle'
|
|
* - parentRelation defaults to 'parent' if not specified
|
|
* - reverse=true flips parent lookup direction
|
|
* - threshold cutoff: possibilities below minPossibility are dropped pre-fusion
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { ParentRule } from '../../src/authorization/rules/ParentRule.js';
|
|
|
|
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
|
|
|
/**
|
|
* Build an arbiter stub whose relationManager.getRelationsFromSrc /
|
|
* getRelationsToDst return relations from a static table, and
|
|
* arbiter.indices.getDirectRelation looks up direct edges.
|
|
* parents: Map<objectId, Array<{src, rel, dst, possibility}>>
|
|
* directEdges: Map<"src|rel|dst", {possibility}>
|
|
* keyMap: Map<nodeId, key>
|
|
*/
|
|
function makeArbiter({ parents, directEdges, keyMap }) {
|
|
return {
|
|
relationManager: {
|
|
getRelationsFromSrc(srcId, relName) {
|
|
if (relName !== 'parent') return [];
|
|
// For 'parent', parents[srcId] lists relationships from src
|
|
return parents.get(srcId) ?? [];
|
|
},
|
|
getRelationsToDst(dstId, relName) {
|
|
if (relName !== 'parent') return [];
|
|
// For 'parent', parents of dst = relations where dst === src (parent->child)
|
|
// Wait, actually the convention is: 'parent' relation means src is the parent
|
|
// of dst. So "get parents of dstId" = relations where dstId === dst.
|
|
return (parents.get(dstId) ?? []).map(r => ({ ...r, _dstRel: true }));
|
|
}
|
|
},
|
|
indices: {
|
|
getDirectRelation(srcId, rel, dstId) {
|
|
const key = `${srcId}|${rel}|${dstId}`;
|
|
return directEdges.get(key) ?? null;
|
|
}
|
|
},
|
|
resolveKey(nodeId) {
|
|
return keyMap.get(nodeId) ?? null;
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('ParentRule evaluation (rigor)', () => {
|
|
it('no parents → possibility=0, reason=no_parent_relationship_path_above_threshold', async () => {
|
|
async function check(userKey, objectKey) {
|
|
const arbiter = makeArbiter({ parents: new Map(), directEdges: new Map(), keyMap: new Map() });
|
|
const rule = new ParentRule(arbiter);
|
|
const result = rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'parent' },
|
|
new Set(),
|
|
'owner',
|
|
{}
|
|
);
|
|
if (result.possibility !== 0) {
|
|
throw new Error(`possibility=${result.possibility}, expected 0 (no parents)`);
|
|
}
|
|
if (result.reason !== 'no_parent_relationship_path_above_threshold') {
|
|
throw new Error(`reason=${result.reason}, expected 'no_parent_relationship_path_above_threshold'`);
|
|
}
|
|
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-parents', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'parent-rule-no-parents', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-parents');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `no-parents contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('one parent with direct access at strength s → possibility=s (with threshold 0)', async () => {
|
|
async function check(parentObjectId, strength) {
|
|
// The object (id=99) has parent=10. userKey='u1', parentKey='p10', userId=1, parentId=10.
|
|
// The user has direct edge to parent at the target relation with possibility=strength.
|
|
const parents = new Map([[99, [{ src: 10, rel: 'parent', dst: 99, possibility: 1 }]]]);
|
|
const directEdges = new Map([[`1|owner|10`, { possibility: strength }]]);
|
|
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [99, 'o99']]);
|
|
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
|
const rule = new ParentRule(arbiter);
|
|
const result = rule.evaluate(
|
|
1, 'u1', 99, 'o99',
|
|
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
|
new Set(),
|
|
'owner',
|
|
{ includeMeta: true }
|
|
);
|
|
// Possibility should be the direct edge strength (since threshold=0)
|
|
if (result.possibility !== strength) {
|
|
throw new Error(`possibility=${result.possibility}, expected ${strength} (strength of direct edge)`);
|
|
}
|
|
if (result.reason !== 'parent_relationship_path_found') {
|
|
throw new Error(`reason=${result.reason}, expected 'parent_relationship_path_found'`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(0, 100),
|
|
rigor.gen.float({ min: 0.01, max: 1 })
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('one-parent-strength', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'parent-rule-one-parent', 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 === 'one-parent-strength');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `one-parent-strength contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('threshold cutoff: possibilities below minPossibility drop to 0', async () => {
|
|
async function check(strength, threshold) {
|
|
const parents = new Map([[99, [{ src: 10, rel: 'parent', dst: 99, possibility: 1 }]]]);
|
|
const directEdges = new Map([[`1|owner|10`, { possibility: strength }]]);
|
|
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [99, 'o99']]);
|
|
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
|
const rule = new ParentRule(arbiter);
|
|
const result = rule.evaluate(
|
|
1, 'u1', 99, 'o99',
|
|
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
|
new Set(),
|
|
'owner',
|
|
{ minPossibility: threshold }
|
|
);
|
|
if (strength >= threshold) {
|
|
if (result.possibility !== strength) {
|
|
throw new Error(`strength=${strength} >= threshold=${threshold}: expected possibility=${strength}, got ${result.possibility}`);
|
|
}
|
|
} else {
|
|
if (result.possibility !== 0) {
|
|
throw new Error(`strength=${strength} < threshold=${threshold}: expected possibility=0, got ${result.possibility}`);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.float({ min: 0.0, max: 1.0 }),
|
|
rigor.gen.float({ min: 0.0, max: 1.0 })
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('threshold-cutoff', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'parent-rule-threshold', 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 === 'threshold-cutoff');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `threshold-cutoff contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('cycle: parentKey === userKey → possibility=0, reason=cycle', async () => {
|
|
async function check() {
|
|
// Object 99 has parent = user 1. User 1 IS the parent of itself.
|
|
const parents = new Map([[99, [{ src: 1, rel: 'parent', dst: 99, possibility: 1 }]]]);
|
|
const directEdges = new Map();
|
|
const keyMap = new Map([[1, 'u1'], [99, 'o99']]);
|
|
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
|
const rule = new ParentRule(arbiter);
|
|
const result = rule.evaluate(
|
|
1, 'u1', 99, 'o99',
|
|
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
|
new Set(),
|
|
'owner',
|
|
{}
|
|
);
|
|
if (result.possibility !== 0) {
|
|
throw new Error(`cycle should yield possibility=0, got ${result.possibility}`);
|
|
}
|
|
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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'parent-rule-cycle', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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`);
|
|
});
|
|
|
|
it('multiple parents → fused via max aggregator (default)', async () => {
|
|
async function check(strength1, strength2) {
|
|
// Object 99 has two parents: 10 and 11. User has direct edges to both.
|
|
const parents = new Map([[99, [
|
|
{ src: 10, rel: 'parent', dst: 99, possibility: 1 },
|
|
{ src: 11, rel: 'parent', dst: 99, possibility: 1 }
|
|
]]]);
|
|
const directEdges = new Map([
|
|
[`1|owner|10`, { possibility: strength1 }],
|
|
[`1|owner|11`, { possibility: strength2 }]
|
|
]);
|
|
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [11, 'p11'], [99, 'o99']]);
|
|
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
|
const rule = new ParentRule(arbiter);
|
|
const result = rule.evaluate(
|
|
1, 'u1', 99, 'o99',
|
|
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
|
new Set(),
|
|
'owner',
|
|
{}
|
|
);
|
|
const expected = Math.max(strength1, strength2);
|
|
if (result.possibility !== expected) {
|
|
throw new Error(`max aggregator: expected ${expected}, got ${result.possibility} (strengths ${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('multi-parent-max', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'parent-rule-multi-parent', 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 === 'multi-parent-max');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `multi-parent-max contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('parentRelation defaults to "parent" when not specified in rule', async () => {
|
|
// Two cases: rule without parentRelation → looks up 'parent'.
|
|
// rule with parentRelation='other' → looks up 'other' (and gets nothing here).
|
|
async function check(useOther) {
|
|
const parents = new Map(); // no parents of either kind
|
|
const directEdges = new Map();
|
|
const keyMap = new Map([[1, 'u1'], [99, 'o99']]);
|
|
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
|
const rule = new ParentRule(arbiter);
|
|
const ruleConfig = useOther
|
|
? { type: 'parent', parentRelation: 'other', relation: 'owner' }
|
|
: { type: 'parent', relation: 'owner' };
|
|
const result = rule.evaluate(
|
|
1, 'u1', 99, 'o99',
|
|
ruleConfig,
|
|
new Set(),
|
|
'owner',
|
|
{}
|
|
);
|
|
// Both should return possibility=0 (no parents)
|
|
if (result.possibility !== 0) {
|
|
throw new Error(`expected 0, got ${result.possibility}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check, rigor.args(rigor.gen.boolean()))],
|
|
rigor.crucible([
|
|
rigor.invariant('parent-relation-default', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'parent-rule-default-relation', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parent-relation-default');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `parent-relation-default contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('result.possibility ∈ [0, 1] always', async () => {
|
|
async function check(strength1, strength2) {
|
|
const parents = new Map([[99, [
|
|
{ src: 10, rel: 'parent', dst: 99, possibility: 1 },
|
|
{ src: 11, rel: 'parent', dst: 99, possibility: 1 }
|
|
]]]);
|
|
const directEdges = new Map([
|
|
[`1|owner|10`, { possibility: strength1 }],
|
|
[`1|owner|11`, { possibility: strength2 }]
|
|
]);
|
|
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [11, 'p11'], [99, 'o99']]);
|
|
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
|
const rule = new ParentRule(arbiter);
|
|
const result = rule.evaluate(
|
|
1, 'u1', 99, 'o99',
|
|
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
|
new Set(),
|
|
'owner',
|
|
{}
|
|
);
|
|
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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'parent-rule-possibility-bounded', 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 === 'possibility-bounded');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|