717ae1031e
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.
215 lines
7.4 KiB
JavaScript
215 lines
7.4 KiB
JavaScript
import { describe, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { Arbiter } from '../../src/core/Arbiter.js';
|
|
|
|
describe('Partial graph overlay', () => {
|
|
test('direct relation resolves from partial graph', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
|
|
const missing = arbiter.check('user:1', 'can_read', 'doc:1');
|
|
assert.strictEqual(missing.possibility, 0);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
assert.strictEqual(result.possibility, 1.0);
|
|
});
|
|
|
|
test('persistent relation wins when partial conflicts on same triple', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
|
|
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.2);
|
|
const baseline = arbiter.check('user:1', 'can_read', 'doc:1');
|
|
assert.strictEqual(baseline.possibility, 0.2);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.9 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
assert.strictEqual(result.possibility, 0.2);
|
|
});
|
|
|
|
test('mixed overlay precedence reports persistent source and audit conflict', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
|
|
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.2);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.9 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
assert.strictEqual(result.decision.possibility, 0.2);
|
|
assert.strictEqual(result.trace.path[0].source, 'persistent');
|
|
assert.strictEqual(result.audit.provenance.partial_fact_used, false);
|
|
assert.strictEqual(result.audit.provenance.provenance_conflicts.length, 1);
|
|
});
|
|
|
|
test('explain marks partial provenance', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
assert.strictEqual(result.trace.path[0].source, 'partial');
|
|
});
|
|
|
|
test('partial nodes participate in chain rules', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('account:1', 'account');
|
|
|
|
arbiter.setRelationConfig('device_link', { type: 'direct' });
|
|
arbiter.setRelationConfig('logged_in_as', { type: 'direct' });
|
|
arbiter.setRelationConfig('can_login', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'device_link', direction: 'out' },
|
|
{ relation: 'logged_in_as', direction: 'out' }
|
|
]
|
|
});
|
|
|
|
const partialGraph = {
|
|
nodes: [
|
|
{ key: 'device:abc', type: 'device' }
|
|
],
|
|
relations: [
|
|
{ src: 'user:1', relation: 'device_link', dst: 'device:abc', possibility: 1.0 },
|
|
{ src: 'device:abc', relation: 'logged_in_as', dst: 'account:1', possibility: 1.0 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.check('user:1', 'can_login', 'account:1', { partialGraph });
|
|
assert.ok(result.possibility > 0);
|
|
});
|
|
|
|
test('multi-hop explain includes partial path sources', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('path', { type: 'multi_hop', relation: 'link', maxDepth: 3 });
|
|
|
|
const partialGraph = {
|
|
nodes: [
|
|
{ key: 'mid:1', type: 'group' }
|
|
],
|
|
relations: [
|
|
{ src: 'user:1', relation: 'link', dst: 'mid:1', possibility: 1.0 },
|
|
{ src: 'mid:1', relation: 'link', dst: 'doc:1', possibility: 1.0 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.explain('user:1', 'path', 'doc:1', { partialGraph });
|
|
const pathSteps = result.trace.rulePaths[0]?.pathSteps || [];
|
|
assert.ok(pathSteps.length > 0);
|
|
for (const step of pathSteps) {
|
|
assert.strictEqual(step.source, 'partial');
|
|
}
|
|
});
|
|
|
|
test('chain collected values include source', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('account:1', 'account');
|
|
|
|
arbiter.setRelationConfig('device_link', { type: 'direct' });
|
|
arbiter.setRelationConfig('logged_in_as', { type: 'direct' });
|
|
arbiter.setRelationConfig('can_login', {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'device_link', direction: 'out' },
|
|
{ relation: 'logged_in_as', direction: 'out' }
|
|
]
|
|
});
|
|
|
|
const partialGraph = {
|
|
nodes: [
|
|
{ key: 'device:abc', type: 'device' }
|
|
],
|
|
relations: [
|
|
{ src: 'user:1', relation: 'device_link', dst: 'device:abc', possibility: 1.0, value: 1 },
|
|
{ src: 'device:abc', relation: 'logged_in_as', dst: 'account:1', possibility: 1.0, value: 1 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.explain('user:1', 'can_login', 'account:1', { partialGraph });
|
|
const pathSteps = result.trace.rulePaths[0]?.pathSteps || [];
|
|
assert.ok(pathSteps.length > 0);
|
|
let sawPartial = false;
|
|
for (const step of pathSteps) {
|
|
assert.ok(step.source === 'partial' || step.source === 'persistent');
|
|
if (step.source === 'partial') sawPartial = true;
|
|
}
|
|
assert.ok(sawPartial);
|
|
const collectedValues = result.trace.values || [];
|
|
assert.ok(collectedValues.length > 0);
|
|
for (const cv of collectedValues) {
|
|
assert.strictEqual(cv.metadata.source, 'partial');
|
|
}
|
|
});
|
|
|
|
test('relational comparator uses partial value provenance', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('account:1', 'account');
|
|
|
|
arbiter.setRelationConfig('device_risk', { type: 'direct' });
|
|
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
|
arbiter.setRelationConfig('risk_ok', {
|
|
type: 'relational_comparator',
|
|
left: {
|
|
rule: { type: 'direct', relation: 'device_risk' },
|
|
extractValue: true,
|
|
valueRelation: 'device_risk',
|
|
evaluateFrom: 'auto'
|
|
},
|
|
right: {
|
|
rule: { type: 'direct', relation: 'risk_limit' },
|
|
extractValue: true,
|
|
valueRelation: 'risk_limit',
|
|
evaluateFrom: 'auto'
|
|
},
|
|
comparator: '<='
|
|
});
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'device_risk', dst: 'account:1', possibility: 1.0, value: 0.2 },
|
|
{ src: 'user:1', relation: 'risk_limit', dst: 'account:1', possibility: 1.0, value: 0.8 }
|
|
]
|
|
};
|
|
|
|
const result = arbiter.explain('user:1', 'risk_ok', 'account:1', { partialGraph });
|
|
assert.ok(result.decision.possibility > 0);
|
|
const comparatorNode = result.trace.path.find(node => node.type === 'relational_comparator');
|
|
const details = comparatorNode?.details || {};
|
|
assert.strictEqual(details.leftSource, 'partial');
|
|
assert.strictEqual(details.rightSource, 'partial');
|
|
});
|
|
});
|