feat: ChainRule condition steps — rule-based final hops for chains
A chain step of the form { rule: <config>, conditionStep: true } is a
condition-gated hop instead of an edge traversal. It is valid only as the
FINAL step: the object is known, so the engine verifies the referenced rule
at (intermediate, object) for each current path. Enables a chain's object-side
hop to reference a defeasible/logical evidence (e.g.
member_of(user,*g){ gated(g,doc) } where gated is WHEN/UNLESS).
- RuleEvaluator wires the ChainRule with itself so condition steps can
evaluate sub-rules recursively.
- ChainRule constructor accepts the ruleEvaluator; the traversal loop handles
a final condition step (min-combined possibility, reliability product,
threshold respect). Non-final condition steps return condition_step_not_final.
- The rule config is part of the chain cache key (JSON.stringify of steps).
Tests: chain-condition-step (grant/deny/missing-edge/non-final/parallel-max).
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* tests/rules/chain-condition-step.test.js — ChainRule CONDITION STEP.
|
||||
*
|
||||
* A chain step of the form { rule: <config>, conditionStep: true } is a
|
||||
* condition-gated hop instead of an edge traversal. It is valid only as the
|
||||
* FINAL step: the object is known, so the engine verifies the referenced rule
|
||||
* at (intermediate, object) for each current path. The DSL compiler emits
|
||||
* these when a chain's object-side hop references a defeasible/logical
|
||||
* evidence (e.g. `member_of(user,*g){ gated(g,doc) }` where gated is
|
||||
* `WHEN can_view(group, doc) UNLESS banned(group)`).
|
||||
*/
|
||||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { RuleEvaluator } from '../../src/authorization/RuleEvaluator.js';
|
||||
import { ChainRule } from '../../src/authorization/rules/ChainRule.js';
|
||||
|
||||
let arbiter, evaluator, chainRule;
|
||||
|
||||
function evalRule(userKey, objectKey, rule, options = {}) {
|
||||
const userId = arbiter.resolveNodeId(userKey);
|
||||
const objectId = arbiter.resolveNodeId(objectKey);
|
||||
return chainRule._evaluateRule(userId, userKey, objectId, objectKey, rule, new Set(), null, {
|
||||
includeMeta: true,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
const CONDITION_CONFIG = {
|
||||
type: 'logical',
|
||||
when: { intersection: { rules: [{ type: 'direct', relation: 'can_view' }], aggregator: 'min' } },
|
||||
// banned(group) is unary → subject-as-object (self-edge), as the DSL emits
|
||||
unless: { union: { rules: [{ type: 'direct', relation: 'banned', _subjectAsObject: true }], aggregator: 'max' } }
|
||||
};
|
||||
|
||||
describe('ChainRule condition step (rule-based final hop)', () => {
|
||||
beforeEach(() => {
|
||||
arbiter = new Arbiter();
|
||||
evaluator = new RuleEvaluator(arbiter);
|
||||
chainRule = new ChainRule(arbiter, evaluator);
|
||||
arbiter.addNode('user:u', 'user');
|
||||
arbiter.addNode('group:g', 'group');
|
||||
arbiter.addNode('doc:d', 'doc');
|
||||
arbiter.setRelationConfig('can_view', { type: 'direct' });
|
||||
arbiter.setRelationConfig('banned', { type: 'direct' });
|
||||
});
|
||||
|
||||
it('grants when the condition holds at the object', () => {
|
||||
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 });
|
||||
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
|
||||
const rule = {
|
||||
type: 'chain',
|
||||
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
|
||||
};
|
||||
const res = evalRule('user:u', 'doc:d', rule);
|
||||
// min(member_of, can_view*(1 - banned)) = min(1.0, 0.7) = 0.7
|
||||
assert.ok(Math.abs(res.possibility - 0.7) < 1e-9, `expected 0.7, got ${res.possibility} (${res.reason})`);
|
||||
});
|
||||
|
||||
it('denies when the condition is defeated at the object', () => {
|
||||
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 });
|
||||
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
|
||||
arbiter.addRelation('group:g', 'banned', 'group:g', { possibility: 1.0 });
|
||||
const rule = {
|
||||
type: 'chain',
|
||||
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
|
||||
};
|
||||
const res = evalRule('user:u', 'doc:d', rule);
|
||||
// min(1.0, 0.7*(1 - 1.0)) = 0
|
||||
assert.equal(res.possibility, 0);
|
||||
});
|
||||
|
||||
it('denies when an earlier edge is missing', () => {
|
||||
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
|
||||
const rule = {
|
||||
type: 'chain',
|
||||
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
|
||||
};
|
||||
const res = evalRule('user:u', 'doc:d', rule);
|
||||
assert.equal(res.possibility, 0);
|
||||
assert.equal(res.reason, 'no_chain_path_found');
|
||||
});
|
||||
|
||||
it('rejects a condition step that is not the final step', () => {
|
||||
const rule = {
|
||||
type: 'chain',
|
||||
steps: [{ rule: CONDITION_CONFIG, conditionStep: true }, 'can_view']
|
||||
};
|
||||
const res = evalRule('user:u', 'doc:d', rule);
|
||||
assert.equal(res.possibility, 0);
|
||||
assert.equal(res.reason, 'condition_step_not_final');
|
||||
});
|
||||
|
||||
it('combines across multiple parallel intermediates (max aggregation)', () => {
|
||||
arbiter.addNode('group:g2', 'group');
|
||||
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 0.5 });
|
||||
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
|
||||
arbiter.addRelation('user:u', 'member_of', 'group:g2', { possibility: 1.0 });
|
||||
arbiter.addRelation('group:g2', 'can_view', 'doc:d', { possibility: 0.8 });
|
||||
const rule = {
|
||||
type: 'chain',
|
||||
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
|
||||
};
|
||||
const res = evalRule('user:u', 'doc:d', rule);
|
||||
// paths: min(0.5,0.7)=0.5 and min(1.0,0.8)=0.8 -> max = 0.8
|
||||
assert.ok(Math.abs(res.possibility - 0.8) < 1e-9, `expected 0.8, got ${res.possibility} (${res.reason})`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user