Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4da3158c63 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@arbiter/core",
|
"name": "@arbiter/core",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
|
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class RuleEvaluator {
|
|||||||
tuple_to_userset: new TupleToUsersetRule(arbiter),
|
tuple_to_userset: new TupleToUsersetRule(arbiter),
|
||||||
multi_hop: new MultiHopRule(arbiter),
|
multi_hop: new MultiHopRule(arbiter),
|
||||||
relational_comparator: new RelationalComparatorRouter(arbiter, this),
|
relational_comparator: new RelationalComparatorRouter(arbiter, this),
|
||||||
chain: new ChainRule(arbiter),
|
chain: new ChainRule(arbiter, this),
|
||||||
challenge: new ChallengeRule(arbiter)
|
challenge: new ChallengeRule(arbiter)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export class ChainRule extends BaseRule {
|
export class ChainRule extends BaseRule {
|
||||||
constructor(arbiter) {
|
constructor(arbiter, ruleEvaluator = null) {
|
||||||
super(arbiter);
|
super(arbiter);
|
||||||
|
this.ruleEvaluator = ruleEvaluator;
|
||||||
|
|
||||||
// Chain-specific caching with HyperbolicLRUCache for better memory management
|
// Chain-specific caching with HyperbolicLRUCache for better memory management
|
||||||
this.maxCacheSize = 2000;
|
this.maxCacheSize = 2000;
|
||||||
@@ -199,7 +200,55 @@ export class ChainRule extends BaseRule {
|
|||||||
? { relation: rawStep, direction: 'out' }
|
? { relation: rawStep, direction: 'out' }
|
||||||
: rawStep;
|
: rawStep;
|
||||||
const { relation: stepRelation, direction } = step;
|
const { relation: stepRelation, direction } = step;
|
||||||
|
|
||||||
|
// CONDITION STEP (rule-based step): a step carrying a `rule` config is a
|
||||||
|
// condition-gated hop, not an edge traversal. It is only valid as the
|
||||||
|
// FINAL step: the object is known, so each current path's node is checked
|
||||||
|
// against the object through the referenced rule (e.g. a defeasible
|
||||||
|
// evidence like `WHEN can_view(group, doc) UNLESS banned(group)`). The
|
||||||
|
// DSL compiler emits these when a chain's object-side hop references a
|
||||||
|
// logical/defeasible evidence. The rule config is part of the chain
|
||||||
|
// cache key (JSON.stringify of steps), so cache correctness is preserved.
|
||||||
|
if (step.rule) {
|
||||||
|
if (stepIndex !== steps.length - 1) {
|
||||||
|
return this._createStandardResult({
|
||||||
|
possibility: 0,
|
||||||
|
reliability: 1.0,
|
||||||
|
...(includeMeta && { meta: null }),
|
||||||
|
reason: 'condition_step_not_final'
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
if (!this.ruleEvaluator) {
|
||||||
|
return this._createStandardResult({
|
||||||
|
possibility: 0,
|
||||||
|
reliability: 1.0,
|
||||||
|
...(includeMeta && { meta: null }),
|
||||||
|
reason: 'condition_step_requires_rule_evaluator'
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
const conditionPaths = [];
|
||||||
|
for (const currentPath of currentPaths) {
|
||||||
|
const condResult = this.ruleEvaluator.evaluateRule(
|
||||||
|
currentPath.id, currentPath.key, objectIdNum, objectKey,
|
||||||
|
step.rule, new Set(visited || []), currentRelation, options
|
||||||
|
);
|
||||||
|
const condPossibility = condResult.possibility ?? 0;
|
||||||
|
if (condPossibility <= 0) continue;
|
||||||
|
const nextPossibility = Math.min(currentPath.possibility, condPossibility);
|
||||||
|
if (fastPath && nextPossibility < minPossibility) continue;
|
||||||
|
conditionPaths.push({
|
||||||
|
id: objectIdNum,
|
||||||
|
key: objectKey,
|
||||||
|
possibility: nextPossibility,
|
||||||
|
reliability: (currentPath.reliability ?? 1.0) * (condResult.reliability ?? 1.0),
|
||||||
|
path: [...currentPath.path, objectKey],
|
||||||
|
pathEntities: [...currentPath.pathEntities, { id: objectIdNum, key: objectKey, source: 'condition' }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentPaths = conditionPaths;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (!stepRelation || !direction) {
|
if (!stepRelation || !direction) {
|
||||||
currentPaths = [];
|
currentPaths = [];
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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