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,558 @@
|
||||
import { RelationalComparatorRule } from '../../src/authorization/rules/RelationalComparatorRule.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('RelationalComparatorRule - Real-world Financial & Authorization Scenarios', () => {
|
||||
let comparatorRule;
|
||||
let arbiter;
|
||||
let ruleEvaluator;
|
||||
|
||||
// Helper function to evaluate rules
|
||||
function evaluateRule(userKey, objectKey, rule, visited = {}, currentRelation = null, options = {}) {
|
||||
const userId = arbiter.resolveNodeId(userKey);
|
||||
const objectId = arbiter.resolveNodeId(objectKey);
|
||||
const mergedOptions = { collectValues: true, includeMeta: true, ...options };
|
||||
return comparatorRule._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, mergedOptions);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh arbiter for each test
|
||||
arbiter = new Arbiter({ embeddingDimensions: 256 });
|
||||
ruleEvaluator = new RuleEvaluator(arbiter);
|
||||
comparatorRule = new RelationalComparatorRule(arbiter, ruleEvaluator);
|
||||
|
||||
// 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('account:alice_checking', 'account');
|
||||
arbiter.addNode('account:alice_savings', 'account');
|
||||
arbiter.addNode('account:team_budget', 'account');
|
||||
arbiter.addNode('feature:premium', 'feature');
|
||||
arbiter.addNode('feature:basic', 'feature');
|
||||
arbiter.addNode('transaction:large_purchase', 'transaction');
|
||||
|
||||
// Configure relations
|
||||
arbiter.setRelationConfig('has_balance', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_price', { type: 'direct' });
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_reputation', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_amount', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_budget', { type: 'direct' });
|
||||
});
|
||||
|
||||
describe('Balance Inquiries - Can User Afford Feature?', () => {
|
||||
it('allows access when user balance exceeds feature price', () => {
|
||||
// Alice has $1000, premium feature costs $800
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 800,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true,
|
||||
ttl: 14 * 24 * 60 * 60 * 1000
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
assert.ok(result.possibility > 0.9, `Expected high possibility, got ${result.possibility}`);
|
||||
assert.strictEqual(result.reason, 'values_compared_comparison_true');
|
||||
});
|
||||
|
||||
it('denies access when user balance is insufficient', () => {
|
||||
// Bob has $500, premium feature costs $800
|
||||
arbiter.addRelation('user:bob', 'has_balance', 'feature:premium', {
|
||||
value: 500,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 800,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true,
|
||||
ttl: 14 * 24 * 60 * 60 * 1000
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:bob', 'feature:premium', rule);
|
||||
assert.ok(result.possibility < 0.1, `Expected low possibility, got ${result.possibility}`);
|
||||
assert.strictEqual(result.reason, 'values_compared_comparison_false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Freshness - No Decay', () => {
|
||||
it('does not reduce confidence for stale balance data', () => {
|
||||
const staleTime = Date.now() - (60 * 60 * 1000);
|
||||
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: staleTime
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 800,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true,
|
||||
ttl: 14 * 24 * 60 * 60 * 1000
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
assert.ok(result.possibility > 0.9, `Expected high possibility without decay, got ${result.possibility}`);
|
||||
});
|
||||
|
||||
it('treats very stale data the same as fresh data', () => {
|
||||
const veryStaleTime = Date.now() - (7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: veryStaleTime
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 800,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true,
|
||||
ttl: 14 * 24 * 60 * 60 * 1000
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
assert.ok(result.possibility > 0.9, `Expected high possibility without decay, got ${result.possibility}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reputation and Risk Scoring', () => {
|
||||
it('evaluates user reputation against threshold', () => {
|
||||
// Alice has high reputation (0.9), we want to check if it's >= 0.8
|
||||
arbiter.addRelation('user:alice', 'has_reputation', 'user:alice', {
|
||||
value: 0.9,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
// Create a threshold entity with the minimum required reputation
|
||||
arbiter.addNode('threshold:reputation', 'threshold');
|
||||
arbiter.addRelation('threshold:reputation', 'has_value', 'threshold:reputation', {
|
||||
value: 0.8,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>=',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_reputation' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_value' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'threshold:reputation', rule);
|
||||
assert.ok(result.possibility > 0.5, `Expected reasonable possibility for reputation check, got ${result.possibility}`);
|
||||
});
|
||||
|
||||
it('evaluates risk score with margin of safety', () => {
|
||||
// Bob has risk score 0.3, but we want 10% margin of safety
|
||||
arbiter.addRelation('user:bob', 'has_risk_score', 'user:bob', {
|
||||
value: 0.3,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_risk_score' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'has_risk_score' },
|
||||
extractValue: false // Use rule possibility as value (0.4 threshold)
|
||||
},
|
||||
marginOfSafety: 1.1 // 10% margin
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:bob', 'user:bob', rule);
|
||||
// 0.3 < (0.4 * 1.1) = 0.44, so should pass
|
||||
assert.ok(result.possibility > 0.8, `Expected high possibility with margin of safety, got ${result.possibility}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Team Budget and Membership', () => {
|
||||
it('checks if team has sufficient budget for transaction', () => {
|
||||
// Engineering team has $5000 budget, transaction costs $3000
|
||||
arbiter.addRelation('team:engineering', 'has_budget', 'transaction:large_purchase', {
|
||||
value: 5000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('transaction:large_purchase', 'has_amount', 'transaction:large_purchase', {
|
||||
value: 3000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>=',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_budget' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_amount' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('team:engineering', 'transaction:large_purchase', rule);
|
||||
assert.ok(result.possibility > 0.9, `Expected high possibility for team budget check, got ${result.possibility}`);
|
||||
});
|
||||
|
||||
it('handles team membership with nested rules', () => {
|
||||
// Alice is member of engineering team, team has budget
|
||||
arbiter.addRelation('user:alice', 'member_of', 'team:engineering', {
|
||||
value: 1.0,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('team:engineering', 'has_budget', 'transaction:large_purchase', {
|
||||
value: 5000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('transaction:large_purchase', 'has_amount', 'transaction:large_purchase', {
|
||||
value: 3000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
// This would typically be handled by a chain rule, but we can test the comparator part
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>=',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_budget' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_amount' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('team:engineering', 'transaction:large_purchase', rule);
|
||||
assert.ok(result.possibility > 0.9, `Expected high possibility for team budget, got ${result.possibility}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OWA Aggregation of Multiple Values', () => {
|
||||
it('aggregates multiple account balances using max', () => {
|
||||
arbiter.setRelationConfig('has_balance_primary', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' });
|
||||
// Alice has multiple balances, we want the max
|
||||
arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', {
|
||||
value: 500,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', {
|
||||
value: 1500,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'has_balance_primary' },
|
||||
{ type: 'direct', relation: 'has_balance_secondary' }
|
||||
],
|
||||
aggregator: 'max'
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
aggregator: 'max' // Use max of all balances
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
// Should use max balance (1500) > price (1000)
|
||||
assert.ok(result.possibility > 0.9, `Expected high possibility using max aggregation, got ${result.possibility}`);
|
||||
});
|
||||
|
||||
it('aggregates multiple account balances using sum', () => {
|
||||
arbiter.setRelationConfig('has_balance_primary', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' });
|
||||
// Alice has multiple balances, we want total available funds
|
||||
arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', {
|
||||
value: 500,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', {
|
||||
value: 300,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'has_balance_primary' },
|
||||
{ type: 'direct', relation: 'has_balance_secondary' }
|
||||
],
|
||||
aggregator: 'sum'
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
aggregator: 'sum' // Use sum of all balances
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
// Should use sum balance (800) < price (1000), so should fail
|
||||
assert.ok(result.possibility < 0.1, `Expected low possibility using sum aggregation, got ${result.possibility}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interval Blurring for Uncertain Values', () => {
|
||||
it('keeps crisp comparisons without blur', () => {
|
||||
const staleTime = Date.now() - (2 * 60 * 60 * 1000);
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 0.7, // Reduced confidence due to uncertainty
|
||||
changed_last_at: staleTime
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 800,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
assert.equal(result.possibility, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
it('handles missing values gracefully', () => {
|
||||
// Alice has no balance relation
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
},
|
||||
fallbackBehavior: 'deny'
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
assert.strictEqual(result.possibility, 0, 'Expected 0 possibility for missing balance');
|
||||
assert.strictEqual(result.reason, 'left_operand_missing_comparison_false');
|
||||
});
|
||||
|
||||
it('handles both operands missing', () => {
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
},
|
||||
fallbackBehavior: 'deny'
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
assert.strictEqual(result.possibility, 0, 'Expected 0 possibility for missing operands');
|
||||
assert.strictEqual(result.reason, 'both_operands_missing_fallback_deny');
|
||||
});
|
||||
|
||||
it('handles inequality with missing operand', () => {
|
||||
// Missing balance but checking != (should return true)
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '!=',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule);
|
||||
assert.strictEqual(result.possibility, 1.0, 'Expected 1.0 possibility for inequality with missing operand');
|
||||
assert.strictEqual(result.reason, 'left_operand_missing_inequality_true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance and Optimization', () => {
|
||||
it('supports fast path optimization', () => {
|
||||
// Simple case that should use fast path
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', {
|
||||
value: 1000,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', {
|
||||
value: 800,
|
||||
possibility: 1.0,
|
||||
changed_last_at: Date.now()
|
||||
});
|
||||
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'has_balance' },
|
||||
extractValue: true
|
||||
},
|
||||
right: {
|
||||
evaluateFrom: 'object',
|
||||
rule: { type: 'direct', relation: 'has_price' },
|
||||
extractValue: true
|
||||
}
|
||||
};
|
||||
|
||||
const result = evaluateRule('user:alice', 'feature:premium', rule, {}, null, { fastPath: true });
|
||||
assert.ok(result.possibility > 0.9, `Expected high possibility with fast path, got ${result.possibility}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user