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.
273 lines
9.1 KiB
JavaScript
273 lines
9.1 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { Arbiter } from '../../src/core/Arbiter.js';
|
|
|
|
function setupDirectArbiter() {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
|
return arbiter;
|
|
}
|
|
|
|
test('ADR-035/042 explainability: audit captures provenance conflict and trust precedence', () => {
|
|
const arbiter = setupDirectArbiter();
|
|
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.25);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.9 }
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
assert.equal(explain.decision.possibility, 0.25);
|
|
assert.equal(explain.trace.path[0].source, 'persistent');
|
|
|
|
const provenance = explain.audit?.provenance;
|
|
assert.ok(provenance);
|
|
assert.equal(provenance.has_partial_inputs, true);
|
|
assert.equal(provenance.partial_fact_used, false);
|
|
assert.equal(provenance.effective_source, 'persistent');
|
|
assert.equal(provenance.provenance_conflicts.length, 1);
|
|
|
|
const conflict = provenance.provenance_conflicts[0];
|
|
assert.equal(conflict.reason_code, 'higher_trust_source_preferred');
|
|
assert.equal(conflict.winner_source, 'persistent');
|
|
assert.equal(conflict.loser_source, 'partial');
|
|
assert.equal(conflict.relation, 'can_read');
|
|
});
|
|
|
|
test('ADR-035/042 explainability: audit marks partial facts used with no conflict', () => {
|
|
const arbiter = setupDirectArbiter();
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 }
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
assert.equal(explain.decision.possibility, 1.0);
|
|
assert.equal(explain.trace.path[0].source, 'partial');
|
|
|
|
const provenance = explain.audit?.provenance;
|
|
assert.ok(provenance);
|
|
assert.equal(provenance.has_partial_inputs, true);
|
|
assert.equal(provenance.partial_fact_used, true);
|
|
assert.equal(provenance.effective_source, 'partial');
|
|
assert.deepEqual(provenance.provenance_conflicts, []);
|
|
});
|
|
|
|
test('ADR-035/042 explainability: chain path conflicts are auditable per edge', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('device:1', 'device');
|
|
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' }
|
|
]
|
|
});
|
|
|
|
arbiter.addRelation('user:1', 'device_link', 'device:1', 0.2);
|
|
arbiter.addRelation('device:1', 'logged_in_as', 'account:1', 0.2);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'device_link', dst: 'device:1', possibility: 0.95 },
|
|
{ src: 'device:1', relation: 'logged_in_as', dst: 'account:1', possibility: 0.95 }
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_login', 'account:1', { partialGraph });
|
|
assert.equal(explain.decision.possibility, 0.2);
|
|
|
|
const conflicts = explain.audit?.provenance?.provenance_conflicts || [];
|
|
assert.ok(conflicts.length >= 2);
|
|
const conflictRels = new Set(conflicts.map((c) => c.relation));
|
|
assert.ok(conflictRels.has('device_link'));
|
|
assert.ok(conflictRels.has('logged_in_as'));
|
|
for (const c of conflicts) {
|
|
assert.equal(c.reason_code, 'higher_trust_source_preferred');
|
|
assert.equal(c.winner_source, 'persistent');
|
|
assert.equal(c.loser_source, 'partial');
|
|
}
|
|
});
|
|
|
|
test('ADR-035/042 explainability: multi-hop path conflicts are auditable per edge', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('group:1', 'group');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
arbiter.setRelationConfig('can_reach', { type: 'multi_hop', relation: 'link', maxDepth: 3 });
|
|
|
|
arbiter.addRelation('user:1', 'link', 'group:1', 0.3);
|
|
arbiter.addRelation('group:1', 'link', 'doc:1', 0.3);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'link', dst: 'group:1', possibility: 0.9 },
|
|
{ src: 'group:1', relation: 'link', dst: 'doc:1', possibility: 0.9 }
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_reach', 'doc:1', { partialGraph });
|
|
assert.equal(explain.decision.possibility, 0.3);
|
|
|
|
const conflicts = explain.audit?.provenance?.provenance_conflicts || [];
|
|
assert.ok(conflicts.length >= 1);
|
|
for (const c of conflicts) {
|
|
assert.equal(c.relation, 'link');
|
|
assert.equal(c.reason_code, 'higher_trust_source_preferred');
|
|
assert.equal(c.winner_source, 'persistent');
|
|
}
|
|
});
|
|
|
|
test('ADR-035/042 explainability: conflict identities respect key redaction mode', () => {
|
|
const arbiter = setupDirectArbiter();
|
|
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.4);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.95 }
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_read', 'doc:1', {
|
|
partialGraph,
|
|
redaction: 'keys'
|
|
});
|
|
|
|
const conflict = explain.audit?.provenance?.provenance_conflicts?.[0];
|
|
assert.ok(conflict);
|
|
assert.notEqual(conflict.src, 'user:1');
|
|
assert.notEqual(conflict.object, 'doc:1');
|
|
assert.equal(conflict.src.length, 64);
|
|
assert.equal(conflict.object.length, 64);
|
|
assert.equal(explain.request.userKey, conflict.src);
|
|
assert.equal(explain.request.objectKey, conflict.object);
|
|
});
|
|
|
|
test('ADR-035/042 explainability: hash redaction keeps keys and still includes hashes', () => {
|
|
const arbiter = setupDirectArbiter();
|
|
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.4);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.95 }
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_read', 'doc:1', {
|
|
partialGraph,
|
|
redaction: 'hash'
|
|
});
|
|
|
|
const conflict = explain.audit?.provenance?.provenance_conflicts?.[0];
|
|
assert.ok(conflict);
|
|
assert.equal(conflict.src, 'user:1');
|
|
assert.equal(conflict.object, 'doc:1');
|
|
assert.equal(typeof explain.request.userKeyHash, 'string');
|
|
assert.equal(typeof explain.request.objectKeyHash, 'string');
|
|
assert.equal(explain.request.userKeyHash.length, 64);
|
|
assert.equal(explain.request.objectKeyHash.length, 64);
|
|
});
|
|
|
|
test('ADR-035/042 explainability: audit provenance contract has stable shape', () => {
|
|
const arbiter = setupDirectArbiter();
|
|
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.4);
|
|
|
|
const partialGraph = {
|
|
relations: [
|
|
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.95 }
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
const provenance = explain.audit?.provenance;
|
|
assert.ok(provenance);
|
|
|
|
assert.deepEqual(Object.keys(provenance).sort(), [
|
|
'effective_source',
|
|
'has_partial_inputs',
|
|
'partial_fact_used',
|
|
'policy_conflicts',
|
|
'provenance_conflicts'
|
|
,
|
|
'used_facts'
|
|
]);
|
|
|
|
assert.equal(typeof provenance.has_partial_inputs, 'boolean');
|
|
assert.equal(typeof provenance.partial_fact_used, 'boolean');
|
|
assert.equal(typeof provenance.effective_source, 'string');
|
|
assert.ok(Array.isArray(provenance.provenance_conflicts));
|
|
assert.ok(Array.isArray(provenance.used_facts));
|
|
assert.ok(Array.isArray(provenance.policy_conflicts));
|
|
assert.ok(provenance.provenance_conflicts.length >= 1);
|
|
|
|
const conflict = provenance.provenance_conflicts[0];
|
|
assert.deepEqual(Object.keys(conflict).sort(), [
|
|
'decision_possibility',
|
|
'loser_source',
|
|
'object',
|
|
'partial_layer_name',
|
|
'partial_possibility',
|
|
'partial_reducer_applied',
|
|
'partial_source_class',
|
|
'persistent_possibility',
|
|
'reason_code',
|
|
'relation',
|
|
'resolution',
|
|
'rule_type',
|
|
'src',
|
|
'winner_source'
|
|
]);
|
|
});
|
|
|
|
test('ADR-042 explainability: used facts and policy conflicts are surfaced', () => {
|
|
const arbiter = new Arbiter();
|
|
arbiter.addNode('user:1', 'user');
|
|
arbiter.addNode('doc:1', 'doc');
|
|
// The direct rule checks 'gateway_context_ref', which IS allowlisted at
|
|
// the request_observed layer (can_read is not).
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'gateway_context_ref' });
|
|
|
|
const partialGraph = {
|
|
options: {
|
|
reducers: {
|
|
delegated_authority: 'strongest'
|
|
}
|
|
},
|
|
relations: [
|
|
{
|
|
src: 'user:1',
|
|
relation: 'gateway_context_ref',
|
|
dst: 'doc:1',
|
|
possibility: 1.0,
|
|
layer_name: 'request_observed',
|
|
source_class: 'gateway_observed'
|
|
}
|
|
]
|
|
};
|
|
|
|
const explain = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
|
const provenance = explain.audit?.provenance;
|
|
assert.ok(provenance);
|
|
|
|
const used = provenance.used_facts[0];
|
|
assert.ok(used);
|
|
assert.equal(used.layer_name, 'request_observed');
|
|
assert.equal(used.source_class, 'gateway_observed');
|
|
assert.equal(used.source, 'partial');
|
|
assert.equal(used.used, true);
|
|
|
|
const policy = provenance.policy_conflicts;
|
|
assert.ok(policy.some((p) => p.kind === 'invalid_reducer_config'));
|
|
});
|