initial commit: @arbiter/core authorization engine with js-rigor hardening
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.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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'));
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
import { PartialGraphContext } from '../../src/core/PartialGraphContext.js';
|
||||
import { getTrustScore } from '../../src/core/partial-graph/reducers.js';
|
||||
|
||||
const LAYERS = [
|
||||
'token_projection',
|
||||
'workflow_overlay',
|
||||
'request_observed',
|
||||
'attested_context',
|
||||
'challenge_evidence',
|
||||
'provenance_overlay',
|
||||
'caller_declared'
|
||||
];
|
||||
|
||||
test('fast-check partial context: stacked same-triple facts honor latest reducer within top trust', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
ts: fc.integer({ min: 1, max: 1000000 }),
|
||||
layer: fc.constantFrom(...LAYERS),
|
||||
value: fc.integer({ min: 1, max: 1000000 })
|
||||
}),
|
||||
{ minLength: 2, maxLength: 20 }
|
||||
),
|
||||
(inputs) => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('request:1', 'request');
|
||||
arbiter.addNode('time:now', 'timestamp');
|
||||
|
||||
const relations = inputs.map((x) => ({
|
||||
src: 'request:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'time:now',
|
||||
value: x.value,
|
||||
updated_last_at: x.ts,
|
||||
changed_last_at: x.ts,
|
||||
layer_name: x.layer
|
||||
}));
|
||||
|
||||
const ctx = new PartialGraphContext(arbiter, {
|
||||
options: { reducers: { request_has_timestamp: 'latest' } },
|
||||
relations
|
||||
});
|
||||
|
||||
const srcId = arbiter.nodeIdByKey.get('request:1');
|
||||
const dstId = arbiter.nodeIdByKey.get('time:now');
|
||||
const direct = ctx.getDirectRelation(srcId, 'request_has_timestamp', dstId);
|
||||
assert.ok(direct);
|
||||
|
||||
const topTrust = Math.max(...inputs.map((x) => getTrustScore(x.layer)));
|
||||
const top = inputs.filter((x) => getTrustScore(x.layer) === topTrust);
|
||||
const expectedTs = Math.max(...top.map((x) => x.ts));
|
||||
assert.equal(direct.updated_last_at, expectedTs);
|
||||
}
|
||||
),
|
||||
{ numRuns: 150 }
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
import { PartialGraphContext } from '../../src/core/PartialGraphContext.js';
|
||||
|
||||
function setupArbiter() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('request:1', 'request');
|
||||
arbiter.addNode('time:now', 'timestamp');
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
test('ADR-042 reducers are first-class in PartialGraphContext conflict resolution', () => {
|
||||
const arbiter = setupArbiter();
|
||||
const ctx = new PartialGraphContext(arbiter, {
|
||||
options: {
|
||||
reducers: {
|
||||
request_has_timestamp: 'latest'
|
||||
}
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
src: 'request:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'time:now',
|
||||
value: 100,
|
||||
updated_last_at: 100,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'request:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'time:now',
|
||||
value: 200,
|
||||
updated_last_at: 200,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const srcId = arbiter.nodeIdByKey.get('request:1');
|
||||
const dstId = arbiter.nodeIdByKey.get('time:now');
|
||||
const direct = ctx.getDirectRelation(srcId, 'request_has_timestamp', dstId);
|
||||
assert.ok(direct);
|
||||
assert.equal(direct.value, 200);
|
||||
const audit = ctx.getReducerAudit();
|
||||
assert.ok(audit.some((entry) => entry.type === 'relation_conflict_resolved'));
|
||||
});
|
||||
|
||||
test('ADR-042 strict conflict mode rejects unresolved same-triple conflicts', () => {
|
||||
const arbiter = setupArbiter();
|
||||
assert.throws(() => {
|
||||
new PartialGraphContext(arbiter, {
|
||||
options: {
|
||||
strictConflicts: true
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
src: 'request:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'time:now',
|
||||
value: 1,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'request:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'time:now',
|
||||
value: 2,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
});
|
||||
}, /partial_graph_conflict_without_reducer/);
|
||||
});
|
||||
|
||||
test('ADR-042 invalid reducer configuration is reported in reducer audit', () => {
|
||||
const arbiter = setupArbiter();
|
||||
const ctx = new PartialGraphContext(arbiter, {
|
||||
options: {
|
||||
reducers: {
|
||||
delegated_authority: 'strongest'
|
||||
}
|
||||
},
|
||||
relations: []
|
||||
});
|
||||
const audit = ctx.getReducerAudit();
|
||||
assert.ok(audit.some((entry) => entry.type === 'invalid_reducer_config'));
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
test('ADR-035/042: first-class core policy config sets conflict mode and reducers', () => {
|
||||
const arbiter = new Arbiter();
|
||||
const initial = arbiter.getPartialGraphPolicy();
|
||||
assert.equal(initial.conflict_mode, 'deterministic');
|
||||
|
||||
arbiter.setPartialGraphPolicy({
|
||||
conflict_mode: 'strict',
|
||||
reducers: {
|
||||
request_has_timestamp: 'latest'
|
||||
}
|
||||
});
|
||||
|
||||
const policy = arbiter.getPartialGraphPolicy();
|
||||
assert.equal(policy.conflict_mode, 'strict');
|
||||
assert.equal(policy.reducers.request_has_timestamp, 'latest');
|
||||
|
||||
const snapshot = arbiter.getPartialGraphPolicySnapshot();
|
||||
assert.equal(snapshot.conflict_mode, 'strict');
|
||||
assert.equal(snapshot.reducer_count, 1);
|
||||
});
|
||||
|
||||
test('ADR-035/042: DSL relation config can declare partial-graph reducer policy', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.setRelationConfig('request_has_timestamp', {
|
||||
type: 'direct',
|
||||
partial_graph: {
|
||||
reducer: 'latest'
|
||||
}
|
||||
});
|
||||
|
||||
const policy = arbiter.getPartialGraphPolicy();
|
||||
assert.equal(policy.reducers.request_has_timestamp, 'latest');
|
||||
});
|
||||
|
||||
test('ADR-035/042: policy-level strict conflict mode is enforced by default', () => {
|
||||
const arbiter = new Arbiter({
|
||||
partialGraphPolicy: {
|
||||
conflict_mode: 'strict'
|
||||
}
|
||||
});
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('obj:1', 'obj');
|
||||
arbiter.setRelationConfig('request_has_id', { type: 'direct' });
|
||||
|
||||
assert.throws(() => {
|
||||
arbiter._createPartialGraphContext({
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'obj:1',
|
||||
value: 1,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'obj:1',
|
||||
value: 2,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
});
|
||||
}, /partial_graph_conflict_without_reducer/);
|
||||
});
|
||||
|
||||
test('ADR-035/042: request options can override core policy conflict mode', () => {
|
||||
const arbiter = new Arbiter({
|
||||
partialGraphPolicy: {
|
||||
conflict_mode: 'strict'
|
||||
}
|
||||
});
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('obj:1', 'obj');
|
||||
arbiter.setRelationConfig('request_has_id', { type: 'direct' });
|
||||
|
||||
const context = arbiter._createPartialGraphContext({
|
||||
options: {
|
||||
conflict_mode: 'deterministic'
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'obj:1',
|
||||
value: 1,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'obj:1',
|
||||
value: 2,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const srcId = arbiter.nodeIdByKey.get('user:1');
|
||||
const dstId = arbiter.nodeIdByKey.get('obj:1');
|
||||
const rel = context.getDirectRelation(srcId, 'request_has_id', dstId);
|
||||
assert.ok(rel);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildLayerConformanceMatrix,
|
||||
getAllowedRelationsForLayer,
|
||||
getAllowedCategoriesForLayer,
|
||||
getProhibitedCategoriesForLayer,
|
||||
getRelationCategory,
|
||||
getSupportedReducersForRelation,
|
||||
isReducerAllowedForRelation,
|
||||
listLayerNames,
|
||||
resolveLayerName,
|
||||
validateReducerConfig,
|
||||
validateClaimsForLayer
|
||||
} from '../../../src/core/partial-graph/layer-registry.js';
|
||||
|
||||
test('ADR-042: layer registry exposes all named layers and aliases', () => {
|
||||
const expected = [
|
||||
'token_projection',
|
||||
'workflow_overlay',
|
||||
'request_observed',
|
||||
'attested_context',
|
||||
'caller_declared',
|
||||
'challenge_evidence',
|
||||
'provenance_overlay',
|
||||
'delegation_evidence',
|
||||
'approval_evidence'
|
||||
];
|
||||
assert.deepEqual(listLayerNames(), expected);
|
||||
assert.equal(resolveLayerName(' workflow_claims '), 'workflow_overlay');
|
||||
assert.equal(resolveLayerName('REQUEST_OBSERVED'), 'request_observed');
|
||||
assert.equal(resolveLayerName(''), null);
|
||||
});
|
||||
|
||||
test('ADR-042: conformance matrix is complete and enforced for all layers', () => {
|
||||
const matrix = buildLayerConformanceMatrix();
|
||||
const names = new Set(listLayerNames());
|
||||
|
||||
assert.equal(matrix.layer_count, names.size);
|
||||
assert.ok(Array.isArray(matrix.layers));
|
||||
assert.equal(matrix.layers.length, names.size);
|
||||
|
||||
for (const layer of matrix.layers) {
|
||||
assert.ok(names.has(layer.layer_name));
|
||||
assert.equal(layer.conformance, 'enforced');
|
||||
assert.ok(Array.isArray(layer.allowed_relations));
|
||||
assert.ok(layer.allowed_relations.length > 0);
|
||||
assert.deepEqual(layer.allowed_relations, getAllowedRelationsForLayer(layer.layer_name));
|
||||
assert.deepEqual(layer.allowed_categories, getAllowedCategoriesForLayer(layer.layer_name));
|
||||
assert.deepEqual(layer.prohibited_categories, getProhibitedCategoriesForLayer(layer.layer_name));
|
||||
}
|
||||
});
|
||||
|
||||
test('ADR-042: relation allowlists are enforced with accepted/rejected claims', () => {
|
||||
const ok = validateClaimsForLayer('request_observed', [
|
||||
{ relation: 'from_ip', object: 'ip:10.0.0.1' },
|
||||
{ relation: 'request_has_id', object: 'req:1' }
|
||||
]);
|
||||
assert.equal(ok.valid, true);
|
||||
assert.equal(ok.accepted_claims.length, 2);
|
||||
assert.equal(ok.rejected_claims.length, 0);
|
||||
|
||||
const bad = validateClaimsForLayer('caller_declared', [
|
||||
{ relation: 'client_fingerprint_evidence', object: 'fp:abc' },
|
||||
{ relation: 'delegated_authority', object: 'resource:x' }
|
||||
]);
|
||||
assert.equal(bad.valid, false);
|
||||
assert.equal(bad.accepted_claims.length, 1);
|
||||
assert.equal(bad.rejected_claims.length, 1);
|
||||
assert.deepEqual(bad.disallowed_relations, ['delegated_authority']);
|
||||
});
|
||||
|
||||
test('ADR-042: invalid layers and caller allowlist restrictions are enforced', () => {
|
||||
const invalid = validateClaimsForLayer('not_a_layer', [
|
||||
{ relation: 'from_ip', object: 'ip:10.0.0.1' }
|
||||
]);
|
||||
assert.equal(invalid.valid, false);
|
||||
assert.equal(invalid.error, 'invalid_layer_type');
|
||||
assert.deepEqual(invalid.allowed_layers, listLayerNames());
|
||||
|
||||
const callerRestricted = validateClaimsForLayer(
|
||||
'token_projection',
|
||||
[
|
||||
{ relation: 'workflow_handoff', object: 'wf:1' },
|
||||
{ relation: 'delegated_authority', object: 'resource:1' }
|
||||
],
|
||||
['workflow_handoff']
|
||||
);
|
||||
assert.equal(callerRestricted.valid, false);
|
||||
assert.equal(callerRestricted.accepted_claims.length, 1);
|
||||
assert.equal(callerRestricted.rejected_claims.length, 1);
|
||||
assert.deepEqual(callerRestricted.disallowed_relations, ['delegated_authority']);
|
||||
});
|
||||
|
||||
test('ADR-042: category-level conformance is enforced', () => {
|
||||
assert.equal(getRelationCategory('from_ip'), 'request_metadata');
|
||||
assert.equal(getRelationCategory('workflow_handoff'), 'workflow_authority');
|
||||
assert.equal(getRelationCategory('unknown_relation'), 'unclassified');
|
||||
|
||||
const result = validateClaimsForLayer('caller_declared', [
|
||||
{ relation: 'workflow_handoff', object: 'wf:1' }
|
||||
]);
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.category_conflicts.length, 1);
|
||||
assert.equal(result.category_conflicts[0].category, 'workflow_authority');
|
||||
});
|
||||
|
||||
test('ADR-042: reducer support is relation-scoped and validated', () => {
|
||||
assert.deepEqual(getSupportedReducersForRelation('request_has_timestamp'), ['latest', 'first', 'unique']);
|
||||
assert.equal(isReducerAllowedForRelation('request_has_timestamp', 'latest'), true);
|
||||
assert.equal(isReducerAllowedForRelation('request_has_timestamp', 'union_refs'), false);
|
||||
|
||||
const validation = validateReducerConfig({
|
||||
request_has_timestamp: 'latest',
|
||||
provenance_hash_ref: 'union_refs',
|
||||
delegated_authority: 'strongest'
|
||||
});
|
||||
|
||||
assert.equal(validation.ok, false);
|
||||
assert.deepEqual(validation.valid, {
|
||||
request_has_timestamp: 'latest',
|
||||
provenance_hash_ref: 'union_refs'
|
||||
});
|
||||
assert.equal(validation.invalid.length, 1);
|
||||
assert.equal(validation.invalid[0].relation, 'delegated_authority');
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { reduceConflictingFacts, listSupportedReducers, getTrustScore } from '../../../src/core/partial-graph/reducers.js';
|
||||
|
||||
const LAYERS = [
|
||||
'token_projection',
|
||||
'workflow_overlay',
|
||||
'request_observed',
|
||||
'attested_context',
|
||||
'challenge_evidence',
|
||||
'provenance_overlay',
|
||||
'caller_declared'
|
||||
];
|
||||
|
||||
const REDUCERS = listSupportedReducers();
|
||||
|
||||
const factArb = fc.record({
|
||||
possibility: fc.double({ min: 0, max: 1, noNaN: true, noDefaultInfinity: true }),
|
||||
reliability: fc.double({ min: 0, max: 1, noNaN: true, noDefaultInfinity: true }),
|
||||
value: fc.oneof(fc.constant(null), fc.integer({ min: -1000, max: 1000 })),
|
||||
updated_last_at: fc.integer({ min: 0, max: 1000000 }),
|
||||
layer_name: fc.constantFrom(...LAYERS)
|
||||
}).map((x) => ({
|
||||
src: 1,
|
||||
rel: 'request_has_timestamp',
|
||||
dst: 2,
|
||||
possibility: x.possibility,
|
||||
reliability: x.reliability,
|
||||
value: x.value,
|
||||
updated_last_at: x.updated_last_at,
|
||||
changed_last_at: x.updated_last_at,
|
||||
attributes: null,
|
||||
layer_name: x.layer_name,
|
||||
source: 'partial'
|
||||
}));
|
||||
|
||||
test('fast-check reducers: winner never comes from lower trust layer', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(factArb, { minLength: 2, maxLength: 15 }),
|
||||
fc.constantFrom(...REDUCERS),
|
||||
(facts, reducer) => {
|
||||
const reduced = reduceConflictingFacts(facts, reducer);
|
||||
if (!reduced.fact) return true;
|
||||
const maxTrust = facts.reduce((m, f) => Math.max(m, getTrustScore(f.layer_name)), -Infinity);
|
||||
return getTrustScore(reduced.fact.layer_name) === maxTrust;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
test('fast-check reducers: latest selects max timestamp within top trust class', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(factArb, { minLength: 2, maxLength: 15 }),
|
||||
(facts) => {
|
||||
const reduced = reduceConflictingFacts(facts, 'latest');
|
||||
if (!reduced.fact) return true;
|
||||
const winnerTrust = getTrustScore(reduced.fact.layer_name);
|
||||
const top = facts.filter((f) => getTrustScore(f.layer_name) === winnerTrust);
|
||||
const expected = top.reduce((m, f) => Math.max(m, f.updated_last_at || 0), -Infinity);
|
||||
return (reduced.fact.updated_last_at || 0) === expected;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { reduceConflictingFacts, listSupportedReducers } from '../../../src/core/partial-graph/reducers.js';
|
||||
|
||||
function fact(overrides = {}) {
|
||||
return {
|
||||
src: 1,
|
||||
rel: 'request_has_timestamp',
|
||||
dst: 2,
|
||||
possibility: 0.7,
|
||||
reliability: 0.9,
|
||||
value: 10,
|
||||
attributes: null,
|
||||
updated_last_at: 100,
|
||||
changed_last_at: 100,
|
||||
layer_name: 'request_observed',
|
||||
source: 'partial',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('ADR-042 reducer set is explicitly supported', () => {
|
||||
assert.deepEqual(listSupportedReducers().sort(), [
|
||||
'dedupe',
|
||||
'first',
|
||||
'ignore_conflict',
|
||||
'latest',
|
||||
'max_value',
|
||||
'min_value',
|
||||
'strongest',
|
||||
'union_refs',
|
||||
'unique',
|
||||
'weakest'
|
||||
]);
|
||||
});
|
||||
|
||||
test('ADR-042 trust filtering applies before reducer selection', () => {
|
||||
const highTrust = fact({ possibility: 0.2, layer_name: 'token_projection' });
|
||||
const lowTrust = fact({ possibility: 0.9, layer_name: 'caller_declared' });
|
||||
const reduced = reduceConflictingFacts([lowTrust, highTrust], 'strongest');
|
||||
assert.equal(reduced.fact.possibility, 0.2);
|
||||
});
|
||||
|
||||
test('ADR-042 ignore_conflict returns absent on incompatible same-trust facts', () => {
|
||||
const a = fact({ value: 10 });
|
||||
const b = fact({ value: 20 });
|
||||
const reduced = reduceConflictingFacts([a, b], 'ignore_conflict');
|
||||
assert.equal(reduced.fact, null);
|
||||
assert.equal(reduced.audit.reason_code, 'ignore_conflict_absent');
|
||||
});
|
||||
|
||||
test('ADR-042 latest reducer picks freshest same-trust fact', () => {
|
||||
const older = fact({ value: 10, updated_last_at: 100 });
|
||||
const newer = fact({ value: 20, updated_last_at: 200 });
|
||||
const reduced = reduceConflictingFacts([older, newer], 'latest');
|
||||
assert.equal(reduced.fact.value, 20);
|
||||
});
|
||||
|
||||
test('ADR-042 min/max reducers select bounded numeric values', () => {
|
||||
const a = fact({ value: 10 });
|
||||
const b = fact({ value: 20 });
|
||||
const minReduced = reduceConflictingFacts([a, b], 'min_value');
|
||||
const maxReduced = reduceConflictingFacts([a, b], 'max_value');
|
||||
assert.equal(minReduced.fact.value, 10);
|
||||
assert.equal(maxReduced.fact.value, 20);
|
||||
});
|
||||
|
||||
test('ADR-042 strongest/weakest are deterministic after trust filtering', () => {
|
||||
const weaker = fact({ possibility: 0.3, reliability: 0.8 });
|
||||
const stronger = fact({ possibility: 0.9, reliability: 1.0 });
|
||||
const strongest = reduceConflictingFacts([weaker, stronger], 'strongest');
|
||||
const weakest = reduceConflictingFacts([weaker, stronger], 'weakest');
|
||||
assert.equal(strongest.fact.possibility, 0.9);
|
||||
assert.equal(weakest.fact.possibility, 0.3);
|
||||
});
|
||||
|
||||
test('ADR-042 union_refs merges reference sets without authority values', () => {
|
||||
const a = fact({ rel: 'provenance_hash_ref', value: null, attributes: { refs: ['hash:a'] } });
|
||||
const b = fact({ rel: 'provenance_hash_ref', value: null, attributes: { refs: ['hash:b', 'hash:a'] } });
|
||||
const reduced = reduceConflictingFacts([a, b], 'union_refs');
|
||||
assert.deepEqual(reduced.fact.attributes.refs, ['hash:a', 'hash:b']);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
test('reducers: direct rule uses reduced same-triple fact', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('ts:1', 'timestamp');
|
||||
arbiter.setRelationConfig('request_has_timestamp', { type: 'direct' });
|
||||
|
||||
const partialGraph = {
|
||||
options: { reducers: { request_has_timestamp: 'latest' } },
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'ts:1',
|
||||
possibility: 0.9,
|
||||
value: 100,
|
||||
updated_last_at: 100,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'ts:1',
|
||||
possibility: 0.2,
|
||||
value: 200,
|
||||
updated_last_at: 200,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'request_has_timestamp', 'ts:1', { partialGraph });
|
||||
assert.equal(result.possibility, 0.2);
|
||||
});
|
||||
|
||||
test('reducers: chain rule consumes reduced edge selections', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('mid:1', 'mid');
|
||||
arbiter.addNode('obj:1', 'obj');
|
||||
arbiter.setRelationConfig('request_has_timestamp', { type: 'direct' });
|
||||
arbiter.setRelationConfig('caller_risk_hint', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_chain', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'request_has_timestamp', direction: 'out' },
|
||||
{ relation: 'caller_risk_hint', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
const partialGraph = {
|
||||
options: {
|
||||
reducers: {
|
||||
request_has_timestamp: 'latest',
|
||||
caller_risk_hint: 'strongest'
|
||||
}
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'mid:1',
|
||||
possibility: 0.95,
|
||||
updated_last_at: 100,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'mid:1',
|
||||
possibility: 0.2,
|
||||
updated_last_at: 200,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'mid:1',
|
||||
relation: 'caller_risk_hint',
|
||||
dst: 'obj:1',
|
||||
possibility: 0.9,
|
||||
layer_name: 'caller_declared'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_chain', 'obj:1', { partialGraph });
|
||||
assert.equal(result.possibility, 0.2);
|
||||
});
|
||||
|
||||
test('reducers: multi-hop rule consumes reduced edge selections', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('hop:1', 'hop');
|
||||
arbiter.addNode('obj:1', 'obj');
|
||||
arbiter.setRelationConfig('caller_risk_hint', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_reach', { type: 'multi_hop', relation: 'caller_risk_hint', maxDepth: 3 });
|
||||
|
||||
const partialGraph = {
|
||||
options: { reducers: { caller_risk_hint: 'strongest' } },
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'caller_risk_hint',
|
||||
dst: 'hop:1',
|
||||
possibility: 0.2,
|
||||
layer_name: 'caller_declared'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'caller_risk_hint',
|
||||
dst: 'hop:1',
|
||||
possibility: 0.85,
|
||||
layer_name: 'caller_declared'
|
||||
},
|
||||
{
|
||||
src: 'hop:1',
|
||||
relation: 'caller_risk_hint',
|
||||
dst: 'obj:1',
|
||||
possibility: 0.9,
|
||||
layer_name: 'caller_declared'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_reach', 'obj:1', { partialGraph });
|
||||
assert.equal(result.possibility, 0.85);
|
||||
});
|
||||
|
||||
test('reducers: tuple_to_userset rule consumes reduced userset relation', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('group:1', 'group');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
arbiter.setRelationConfig('request_has_timestamp', { type: 'direct' });
|
||||
arbiter.setRelationConfig('request_has_id', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: 'request_has_id',
|
||||
computedRelation: 'request_has_timestamp',
|
||||
reverse: false
|
||||
});
|
||||
|
||||
const partialGraph = {
|
||||
options: { reducers: { request_has_timestamp: 'latest' } },
|
||||
relations: [
|
||||
{
|
||||
src: 'doc:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'group:1',
|
||||
possibility: 1,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'group:1',
|
||||
possibility: 0.95,
|
||||
updated_last_at: 100,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'group:1',
|
||||
possibility: 0.25,
|
||||
updated_last_at: 200,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_access', 'doc:1', { partialGraph });
|
||||
assert.equal(result.possibility, 0.25);
|
||||
});
|
||||
|
||||
test('reducers: relational comparator uses reduced operand values', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('feature:1', 'feature');
|
||||
|
||||
arbiter.setRelationConfig('request_has_timestamp', { type: 'direct' });
|
||||
arbiter.setRelationConfig('request_has_id', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_pay', {
|
||||
type: 'relational_comparator',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'request_has_timestamp' },
|
||||
extractValue: true,
|
||||
aggregation: 'max',
|
||||
decayRate: 0,
|
||||
decayFunction: 'rational'
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'request_has_id', evaluateFrom: 'object' },
|
||||
extractValue: true,
|
||||
aggregation: 'min',
|
||||
decayRate: 0,
|
||||
decayFunction: 'rational',
|
||||
evaluateFrom: 'object'
|
||||
},
|
||||
comparator: '>=',
|
||||
fallbackBehavior: 'deny'
|
||||
});
|
||||
|
||||
const partialGraph = {
|
||||
options: { reducers: { request_has_timestamp: 'latest' } },
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'feature:1',
|
||||
value: 120,
|
||||
possibility: 1,
|
||||
updated_last_at: 100,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'request_has_timestamp',
|
||||
dst: 'feature:1',
|
||||
value: 20,
|
||||
possibility: 1,
|
||||
updated_last_at: 200,
|
||||
layer_name: 'request_observed'
|
||||
},
|
||||
{
|
||||
src: 'feature:1',
|
||||
relation: 'request_has_id',
|
||||
dst: 'feature:1',
|
||||
value: 50,
|
||||
possibility: 1,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_pay', 'feature:1', { partialGraph });
|
||||
assert.ok(result.possibility < 0.5);
|
||||
});
|
||||
Reference in New Issue
Block a user