8f863275c2
The binary branch of AuthorizationChecker.check rebuilt its options with a fixed six-field object, silently discarding partialGraphContext (and any other caller option) — binary checks denied grants the normal path allowed. Now spreads all caller options through. binary-partial-parity pins: partial grants above the threshold allow, below deny, persistent wins over partial, and binary decisions agree with normal decisions on the same overlay.
165 lines
6.7 KiB
JavaScript
165 lines
6.7 KiB
JavaScript
/**
|
|
* rigor/binary-partial-parity.test.js — binary-mode decisions with
|
|
* partial graphs.
|
|
*
|
|
* Bug-28 regression pin: the binary branch of AuthorizationChecker.check
|
|
* rebuilt its options with a fixed six-field object and dropped
|
|
* partialGraphContext, so a binary check with a partial grant denied
|
|
* everything. Contracts pinned:
|
|
* - binary decisions honor partial facts: a partial grant above
|
|
* minAllowPossibility allows; below it denies;
|
|
* - persistent relations still win over partial facts in binary mode;
|
|
* - binary and normal decisions agree on the same partial overlay
|
|
* (binary.allow === (normalPossibility >= threshold) for direct
|
|
* configs).
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { Arbiter } from '../../src/index.js';
|
|
|
|
const USERS = 2;
|
|
const DOCS = 2;
|
|
const NODES = USERS + DOCS;
|
|
const nodeKey = (id) => (id < USERS ? `u:${id}` : `doc:${id - USERS}`);
|
|
const THRESHOLD = 0.6;
|
|
|
|
function makeWrapper() {
|
|
const arbiter = new Arbiter();
|
|
for (let i = 0; i < NODES; i++) {
|
|
arbiter.addNode(nodeKey(i), i < USERS ? 'user' : 'doc');
|
|
}
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
const tuples = new Map();
|
|
const partialTuples = new Map();
|
|
const tupleKey = (s, r, d) => `${s}|${r}|${d}`;
|
|
const ops = [];
|
|
|
|
const wrapper = {
|
|
engine: arbiter,
|
|
tuples,
|
|
partialTuples,
|
|
add(src, rel, dst, p) {
|
|
arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p });
|
|
tuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p);
|
|
return { ok: true };
|
|
},
|
|
addPartial(src, rel, dst, p) {
|
|
partialTuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p);
|
|
return { ok: true };
|
|
},
|
|
check(src, rel, dst) {
|
|
const baseRel = rel === 'can_read' ? 'owner' : rel;
|
|
const key = tupleKey(nodeKey(src), baseRel, nodeKey(dst));
|
|
const partialSpec = {
|
|
relations: [...partialTuples.entries()].map(([k, p]) => {
|
|
const [s, r, d] = k.split('|');
|
|
return { src: s, relation: r, dst: d, possibility: p };
|
|
})
|
|
};
|
|
const binaryRes = arbiter.check(nodeKey(src), rel, nodeKey(dst), {
|
|
binary: true,
|
|
minAllowPossibility: THRESHOLD,
|
|
maxDenyPossibility: THRESHOLD,
|
|
partialGraph: partialSpec
|
|
});
|
|
const normalRes = arbiter.check(nodeKey(src), rel, nodeKey(dst), { partialGraph: partialSpec });
|
|
const persistentValue = tuples.get(key) ?? 0;
|
|
const partialValue = partialTuples.get(key) ?? 0;
|
|
return {
|
|
binaryAllow: binaryRes.allow,
|
|
binaryReason: binaryRes.reason,
|
|
normalPossibility: normalRes.possibility,
|
|
persistentValue,
|
|
partialValue,
|
|
expected: persistentValue > 0 ? persistentValue : partialValue
|
|
};
|
|
},
|
|
clone() {
|
|
const fresh = makeWrapper();
|
|
for (const op of ops) {
|
|
const [name, ...args] = op;
|
|
fresh[name](...args);
|
|
}
|
|
return fresh;
|
|
}
|
|
};
|
|
|
|
const record = (name, fn) => (...args) => {
|
|
const res = fn(...args);
|
|
ops.push([name, ...args]);
|
|
return res;
|
|
};
|
|
wrapper.add = record('add', wrapper.add);
|
|
wrapper.addPartial = record('addPartial', wrapper.addPartial);
|
|
wrapper.check = record('check', wrapper.check);
|
|
return wrapper;
|
|
}
|
|
|
|
describe('Binary mode with partial graphs (rigor)', () => {
|
|
it('FIXED MATRIX: binary honors partial grants; persistent wins; decisions agree', () => {
|
|
const arb = new Arbiter();
|
|
arb.addNode('u:0', 'user');
|
|
arb.addNode('doc:0', 'doc');
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
|
|
const grant = arb.check('u:0', 'can_read', 'doc:0', {
|
|
binary: true,
|
|
minAllowPossibility: THRESHOLD,
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }] }
|
|
});
|
|
assert.equal(grant.allow, true, 'partial grant above threshold allows');
|
|
assert.equal(grant.possibility, 0.9, 'binary returns the partial possibility');
|
|
|
|
const weak = arb.check('u:0', 'can_read', 'doc:0', {
|
|
binary: true,
|
|
minAllowPossibility: THRESHOLD,
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.2 }] }
|
|
});
|
|
assert.equal(weak.allow, false, 'partial grant below threshold denies');
|
|
|
|
arb.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
|
const persistent = arb.check('u:0', 'can_read', 'doc:0', {
|
|
binary: true,
|
|
minAllowPossibility: THRESHOLD,
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.3 }] }
|
|
});
|
|
assert.equal(persistent.allow, true, 'persistent 0.8 wins over partial 0.3');
|
|
});
|
|
|
|
it('PROPERTY CAMPAIGN: binary and normal decisions agree on the same overlay', async () => {
|
|
const result = await rigor.campaign(
|
|
[rigor.object('graph', makeWrapper, [
|
|
rigor.method('add', function (s, r, d, p) { return this.add(s, r, d, p); },
|
|
rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.constant('owner'), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.1, 0.3, 0.5, 0.7, 0.9]))),
|
|
rigor.method('addPartial', function (s, r, d, p) { return this.addPartial(s, r, d, p); },
|
|
rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.constant('owner'), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.4, 0.6, 0.8, 0.95]))),
|
|
rigor.method('check', function (s, r, d) { return this.check(s, r, d); },
|
|
rigor.args(rigor.gen.int(0, USERS - 1), rigor.gen.constant('can_read'), rigor.gen.oneOf([2, 3])))
|
|
])],
|
|
rigor.crucible([
|
|
rigor.invariant('binary decision equals normal decision at threshold', (ctx) => {
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
const { binaryAllow, normalPossibility } = ctx.actual;
|
|
const normalAllow = normalPossibility >= THRESHOLD;
|
|
return binaryAllow === normalAllow;
|
|
}),
|
|
rigor.invariant('expected overlay value matches the engine', (ctx) => {
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
const { normalPossibility, expected } = ctx.actual;
|
|
return Math.abs(normalPossibility - expected) < 1e-9;
|
|
}),
|
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
|
])
|
|
).run({ effort: 400, seed: 'binary-partial-parity', maxTraceLength: 25 });
|
|
|
|
const inv = result.crucibleVerdict;
|
|
assert.equal(inv.passed, true, [
|
|
`binary/partial parity violated in ${inv.failureCount} cases:`,
|
|
...result.failures.slice(0, 3).map((f) =>
|
|
` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
|
|
)
|
|
].join('\n'));
|
|
});
|
|
});
|