363 lines
12 KiB
JavaScript
363 lines
12 KiB
JavaScript
|
|
import { ComputedRule } from '../../src/authorization/rules/ComputedRule.js';
|
||
|
|
import { Arbiter } from '../../src/core/Arbiter.js';
|
||
|
|
import { RuleEvaluator } from '../../src/authorization/RuleEvaluator.js';
|
||
|
|
import { describe, it, beforeEach } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
|
||
|
|
describe('ComputedRule - Recursive Authorization Delegation', () => {
|
||
|
|
let computedRule;
|
||
|
|
let arbiter;
|
||
|
|
let ruleEvaluator;
|
||
|
|
|
||
|
|
// Helper function to evaluate rules
|
||
|
|
function evaluateRule(userKey, objectKey, rule, visited = new Set(), currentRelation = null, options = {}) {
|
||
|
|
const userId = arbiter.nodeIdByKey.get(userKey);
|
||
|
|
const objectId = arbiter.nodeIdByKey.get(objectKey);
|
||
|
|
const finalOptions = { includeMeta: true, ...options };
|
||
|
|
return computedRule._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, finalOptions);
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
// Create fresh arbiter for each test
|
||
|
|
arbiter = new Arbiter({ embeddingDimensions: 256 });
|
||
|
|
ruleEvaluator = new RuleEvaluator(arbiter);
|
||
|
|
computedRule = new ComputedRule(arbiter);
|
||
|
|
|
||
|
|
// Set up realistic entities
|
||
|
|
arbiter.addNode('user:alice', 'user');
|
||
|
|
arbiter.addNode('user:bob', 'user');
|
||
|
|
arbiter.addNode('user:charlie', 'user');
|
||
|
|
arbiter.addNode('team:engineering', 'team');
|
||
|
|
arbiter.addNode('team:marketing', 'team');
|
||
|
|
arbiter.addNode('project:secret', 'project');
|
||
|
|
arbiter.addNode('project:public', 'project');
|
||
|
|
arbiter.addNode('document:classified', 'document');
|
||
|
|
arbiter.addNode('document:public', 'document');
|
||
|
|
|
||
|
|
// Configure relations
|
||
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('can_access', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('can_write', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('owns', { type: 'direct' });
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Basic Computed Relation Evaluation', () => {
|
||
|
|
it('delegates to authorization checker for simple relations', () => {
|
||
|
|
// Alice can access project:secret directly
|
||
|
|
arbiter.addRelation('user:alice', 'can_access', 'project:secret', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.9, `Expected high possibility, got ${result.possibility}`);
|
||
|
|
// CI-001 fix: computed rule delegates to fast path → 'direct_match'
|
||
|
|
assert.strictEqual(result.reason, 'direct_match');
|
||
|
|
assert.ok(result.meta, 'Expected meta information');
|
||
|
|
assert.strictEqual(result.meta.ruleType, 'computed');
|
||
|
|
assert.strictEqual(result.meta.computedRelation, 'can_access');
|
||
|
|
assert.strictEqual(result.meta.delegated, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles denied access through computed relations', () => {
|
||
|
|
// Bob has no access to project:secret
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:bob', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.strictEqual(result.possibility, 0, 'Expected no access');
|
||
|
|
// CI-001 fix: fast path → 'no_relation'
|
||
|
|
assert.strictEqual(result.reason, 'no_relation');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('propagates collected values from delegated evaluation', () => {
|
||
|
|
// Alice can access project:secret with a value
|
||
|
|
arbiter.addRelation('user:alice', 'can_access', 'project:secret', {
|
||
|
|
value: 100,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.9, `Expected high possibility, got ${result.possibility}`);
|
||
|
|
// The collectedValues should be propagated from the delegated evaluation
|
||
|
|
assert.ok(Array.isArray(result.collectedValues), 'Expected collectedValues array');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Complex Authorization Scenarios', () => {
|
||
|
|
it('handles team membership through computed relations', () => {
|
||
|
|
// Alice is member of engineering team, team can access project
|
||
|
|
arbiter.addRelation('user:alice', 'member_of', 'team:engineering', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
arbiter.addRelation('team:engineering', 'can_access', 'project:secret', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
// Configure can_access to use chain rule for team membership
|
||
|
|
arbiter.setRelationConfig('can_access', {
|
||
|
|
type: 'chain',
|
||
|
|
steps: [
|
||
|
|
{ relation: 'member_of', direction: 'out' },
|
||
|
|
{ relation: 'can_access', direction: 'out' }
|
||
|
|
]
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.9, `Expected high possibility through team membership, got ${result.possibility}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles nested computed relations', () => {
|
||
|
|
// Alice can read documents that she can access
|
||
|
|
arbiter.addRelation('user:alice', 'can_access', 'document:classified', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
// Configure can_read to use computed relation
|
||
|
|
arbiter.setRelationConfig('can_read', {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_read'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'document:classified', rule);
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.9, `Expected high possibility through nested computation, got ${result.possibility}`);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Performance and Optimization', () => {
|
||
|
|
it('tracks evaluation performance', () => {
|
||
|
|
arbiter.addRelation('user:alice', 'can_access', 'project:secret', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule, new Set(), null, { trackEvaluation: true });
|
||
|
|
|
||
|
|
assert.ok(result.evaluation, 'Expected evaluation tracking');
|
||
|
|
assert.ok(result.evaluation.evaluationStarted, 'Expected start time');
|
||
|
|
assert.ok(result.evaluation.evaluationCompleted, 'Expected completion time');
|
||
|
|
assert.ok(result.evaluation.evaluationDuration >= 0, 'Expected positive duration');
|
||
|
|
assert.strictEqual(result.evaluation.type, 'computed');
|
||
|
|
assert.strictEqual(result.evaluation.computedRelation, 'can_access');
|
||
|
|
});
|
||
|
|
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Error Handling and Edge Cases', () => {
|
||
|
|
it('handles missing relation configuration', () => {
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'nonexistent_relation'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.strictEqual(result.possibility, 0, 'Expected no access for missing relation');
|
||
|
|
assert.strictEqual(result.reason, 'no_config');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles circular dependencies gracefully', () => {
|
||
|
|
// Set up circular dependency: can_access -> can_read -> can_access
|
||
|
|
arbiter.setRelationConfig('can_access', {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_read'
|
||
|
|
});
|
||
|
|
arbiter.setRelationConfig('can_read', {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
// Should handle circular dependency without infinite recursion
|
||
|
|
assert.ok(result.possibility >= 0, 'Expected valid result despite circular dependency');
|
||
|
|
assert.ok(result.possibility <= 1, 'Expected valid possibility range');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves visited set for cycle detection', () => {
|
||
|
|
const visited = new Set();
|
||
|
|
visited.add('user:alice:can_access:project:secret');
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule, visited);
|
||
|
|
|
||
|
|
// Should respect the visited set to prevent cycles
|
||
|
|
assert.ok(result.possibility >= 0, 'Expected valid result with visited set');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Value Context and Metadata', () => {
|
||
|
|
it('preserves value context through delegation', () => {
|
||
|
|
arbiter.addRelation('user:alice', 'can_access', 'project:secret', {
|
||
|
|
value: 100,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
const valueContext = { someValue: 42 };
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule, new Set(), null, {
|
||
|
|
valueContext,
|
||
|
|
trackEvaluation: true
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.9, 'Expected successful evaluation');
|
||
|
|
assert.ok(result.evaluation, 'Expected evaluation tracking');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles different relation types through delegation', () => {
|
||
|
|
// Test with different relation types that might be configured
|
||
|
|
const relations = ['can_access', 'can_read', 'can_write', 'owns'];
|
||
|
|
|
||
|
|
for (const relation of relations) {
|
||
|
|
arbiter.addRelation('user:alice', relation, 'project:secret', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: relation
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.9, `Expected high possibility for ${relation}, got ${result.possibility}`);
|
||
|
|
assert.strictEqual(result.meta.computedRelation, relation);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Integration with Other Rules', () => {
|
||
|
|
it('works with logical operators', () => {
|
||
|
|
// Alice can access through multiple paths
|
||
|
|
arbiter.addRelation('user:alice', 'can_access', 'project:secret', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 0.8,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
arbiter.addRelation('user:alice', 'owns', 'project:secret', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 0.9,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
// Configure can_access to use union of direct and computed relations
|
||
|
|
arbiter.setRelationConfig('can_access', {
|
||
|
|
type: 'logical',
|
||
|
|
union: {
|
||
|
|
rules: [
|
||
|
|
{ type: 'direct' },
|
||
|
|
{ type: 'computed', relation: 'owns' }
|
||
|
|
]
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.8, `Expected high possibility through union, got ${result.possibility}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles complex nested rule configurations', () => {
|
||
|
|
// Set up a complex scenario: user -> team -> project access
|
||
|
|
arbiter.addRelation('user:alice', 'member_of', 'team:engineering', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
arbiter.addRelation('team:engineering', 'can_access', 'project:secret', {
|
||
|
|
value: 1.0,
|
||
|
|
possibility: 1.0,
|
||
|
|
changed_last_at: Date.now()
|
||
|
|
});
|
||
|
|
|
||
|
|
// Configure a complex rule that combines multiple approaches
|
||
|
|
arbiter.setRelationConfig('can_access', {
|
||
|
|
type: 'logical',
|
||
|
|
union: {
|
||
|
|
rules: [
|
||
|
|
{ type: 'direct' },
|
||
|
|
{
|
||
|
|
type: 'chain',
|
||
|
|
steps: [
|
||
|
|
{ relation: 'member_of', direction: 'out' },
|
||
|
|
{ relation: 'can_access', direction: 'out' }
|
||
|
|
]
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const rule = {
|
||
|
|
type: 'computed',
|
||
|
|
relation: 'can_access'
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateRule('user:alice', 'project:secret', rule);
|
||
|
|
|
||
|
|
assert.ok(result.possibility > 0.9, `Expected high possibility through complex rule, got ${result.possibility}`);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|