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,228 @@
|
||||
import { Arbiter } from '../src/core/Arbiter.js';
|
||||
|
||||
const arbiter = new Arbiter();
|
||||
const SIZE = 5000;
|
||||
|
||||
// Build scenario
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
arbiter.addNode(`user:${i}`, 'user');
|
||||
arbiter.addNode(`resource:${i}`, 'resource');
|
||||
const isSafe = i % 2 === 0;
|
||||
arbiter.addRelation(`user:${i}`, 'risk_score', `resource:${i}`, 1.0, { value: isSafe ? 20 : 80 });
|
||||
arbiter.addRelation(`user:${i}`, 'risk_bonus', `resource:${i}`, 1.0, { value: isSafe ? 5 : 15 });
|
||||
arbiter.addRelation(`user:${i}`, 'risk_noise', `resource:${i}`, 1.0, { value: isSafe ? 0 : 10 });
|
||||
arbiter.addRelation(`resource:${i}`, 'risk_limit', `resource:${i}`, 1.0, { value: 40 });
|
||||
arbiter.addRelation(`resource:${i}`, 'risk_cap', `resource:${i}`, 1.0, { value: 45 });
|
||||
}
|
||||
|
||||
['risk_score', 'risk_bonus', 'risk_noise', 'risk_limit', 'risk_cap'].forEach(r =>
|
||||
arbiter.setRelationConfig(r, { type: 'direct' }));
|
||||
|
||||
arbiter.setRelationConfig('risk_ok_owa', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'risk_score' },
|
||||
{ type: 'direct', relation: 'risk_bonus' },
|
||||
{ type: 'direct', relation: 'risk_noise' }
|
||||
],
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.5, 0.3, 0.2]
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_score',
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.5, 0.3, 0.2]
|
||||
},
|
||||
right: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'risk_limit' },
|
||||
{ type: 'direct', relation: 'risk_cap' }
|
||||
],
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.6, 0.4]
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'object',
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.6, 0.4]
|
||||
}
|
||||
});
|
||||
|
||||
// Warm up
|
||||
for (let i = 0; i < 100; i++) {
|
||||
arbiter.check(`user:${i}`, 'risk_ok_owa', `resource:${i}`, { fastPath: true });
|
||||
}
|
||||
|
||||
const ITERATIONS = 2000;
|
||||
|
||||
function measure(label, fn) {
|
||||
const start = process.hrtime.bigint();
|
||||
for (let i = 0; i < ITERATIONS; i++) fn(i % SIZE);
|
||||
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
return { label, ms, qps: Math.round((ITERATIONS / ms) * 1000), perOp: ms / ITERATIONS };
|
||||
}
|
||||
|
||||
console.log('=== AuthChecker.check() Breakdown ===\n');
|
||||
|
||||
const config = arbiter.relationConfigs.get('risk_ok_owa');
|
||||
const ruleEval = arbiter.authChecker.ruleEvaluator;
|
||||
|
||||
// What AuthChecker.check does for a relational_comparator:
|
||||
// 1. Parse options (backward compat check)
|
||||
// 2. Get config from relationConfigs
|
||||
// 3. Check if fast path (NO - it's relational_comparator, not direct)
|
||||
// 4. Call _ensureIndicesBuilt
|
||||
// 5. Create visitKey object
|
||||
// 6. Scan visited set for cycles
|
||||
// 7. Add visitKey to visited
|
||||
// 8. Get userId and objectId from nodeIdByKey
|
||||
// 9. Check if config exists
|
||||
// 10. Build evaluationPath object
|
||||
// 11. Call ruleEvaluator.evaluateRule (since no union/intersection/exclusion at top level)
|
||||
// 12. Iterate through collected rules and call ruleEvaluator for each
|
||||
// 13. Build result object with meta
|
||||
|
||||
// Let's measure each step
|
||||
|
||||
// Step 1-2: Options parsing + config lookup
|
||||
const configGet = measure('Config lookup', () => {
|
||||
arbiter.relationConfigs.get('risk_ok_owa');
|
||||
});
|
||||
console.log(`Config lookup: ${(configGet.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 4: _ensureIndicesBuilt
|
||||
const ensureIndices = measure('_ensureIndicesBuilt', () => {
|
||||
arbiter.relationManager._ensureIndicesBuilt();
|
||||
});
|
||||
console.log(`_ensureIndicesBuilt: ${(ensureIndices.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 5: Create visitKey object
|
||||
const createVisitKey = measure('Create visitKey object', (idx) => {
|
||||
const visitKey = { userKey: `user:${idx}`, relation: 'risk_ok_owa', objectKey: `resource:${idx}` };
|
||||
});
|
||||
console.log(`Create visitKey: ${(createVisitKey.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 6: Scan visited set (empty)
|
||||
const visited = new Set();
|
||||
const scanEmpty = measure('Scan empty visited', () => {
|
||||
for (const v of visited) {
|
||||
if (v.userKey === 'user:0') break;
|
||||
}
|
||||
});
|
||||
console.log(`Scan empty visited: ${(scanEmpty.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 7: Add to visited
|
||||
const addVisited = measure('Add to visited', (idx) => {
|
||||
const s = new Set();
|
||||
s.add({ userKey: `user:${idx}`, relation: 'risk_ok_owa', objectKey: `resource:${idx}` });
|
||||
});
|
||||
console.log(`Add to visited (new Set + add): ${(addVisited.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 8: Get IDs
|
||||
const getIds = measure('Get user/object IDs', (idx) => {
|
||||
arbiter.nodeIdByKey.get(`user:${idx}`);
|
||||
arbiter.nodeIdByKey.get(`resource:${idx}`);
|
||||
});
|
||||
console.log(`Get IDs: ${(getIds.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 10: Build evaluationPath object
|
||||
const buildEvalPath = measure('Build evaluationPath object', (idx) => {
|
||||
const evaluationPath = {
|
||||
userKey: `user:${idx}`,
|
||||
relation: 'risk_ok_owa',
|
||||
objectKey: `resource:${idx}`,
|
||||
config: config,
|
||||
rules: [],
|
||||
visitedPath: [] // Would be Array.from(_visited) but that's expensive
|
||||
};
|
||||
});
|
||||
console.log(`Build evaluationPath: ${(buildEvalPath.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 10b: Array.from(_visited) - THIS IS LIKELY EXPENSIVE
|
||||
const visitedWith5 = new Set();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
visitedWith5.add({ userKey: `user:${i}`, relation: 'test', objectKey: `resource:${i}` });
|
||||
}
|
||||
const arrayFrom = measure('Array.from(visited) with 5 entries', () => {
|
||||
Array.from(visitedWith5);
|
||||
});
|
||||
console.log(`Array.from(visited) x5: ${(arrayFrom.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Step 11: ruleEvaluator.evaluateRule for comparator
|
||||
const evalRule = measure('ruleEvaluator.evaluateRule', (idx) => {
|
||||
const userId = arbiter.nodeIdByKey.get(`user:${idx}`);
|
||||
const objectId = arbiter.nodeIdByKey.get(`resource:${idx}`);
|
||||
ruleEval.evaluateRule(userId, `user:${idx}`, objectId, `resource:${idx}`,
|
||||
config, new Set(), 'risk_ok_owa', { fastPath: true });
|
||||
});
|
||||
console.log(`ruleEvaluator.evaluateRule: ${(evalRule.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Full check for comparison
|
||||
const fullCheck = measure('Full arbiter.check', (idx) => {
|
||||
arbiter.check(`user:${idx}`, 'risk_ok_owa', `resource:${idx}`, { fastPath: true });
|
||||
});
|
||||
console.log(`Full arbiter.check: ${(fullCheck.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Difference analysis
|
||||
const overhead = fullCheck.perOp - evalRule.perOp;
|
||||
console.log(`\n=== AuthChecker overhead: ${(overhead * 1000).toFixed(2)}µs ===`);
|
||||
|
||||
// What's causing it?
|
||||
const sumOfParts = configGet.perOp + ensureIndices.perOp + createVisitKey.perOp +
|
||||
addVisited.perOp + getIds.perOp + buildEvalPath.perOp + arrayFrom.perOp;
|
||||
console.log(`Sum of measured parts: ${(sumOfParts * 1000).toFixed(2)}µs`);
|
||||
console.log(`Unaccounted overhead: ${((overhead - sumOfParts) * 1000).toFixed(2)}µs`);
|
||||
|
||||
// Check what happens inside RuleEvaluator.evaluateRule
|
||||
console.log('\n=== RuleEvaluator.evaluateRule Breakdown ===\n');
|
||||
|
||||
// The evaluateRule does:
|
||||
// 1. Extract binary, valueContext from options
|
||||
// 2. Convert string IDs to numeric (already numeric here)
|
||||
// 3. Check if rule needs values (_ruleRequiresValues) - THIS COULD BE EXPENSIVE
|
||||
// 4. Create enhanced options
|
||||
// 5. Route to handler
|
||||
|
||||
const needsValues = measure('_ruleRequiresValues', () => {
|
||||
ruleEval._ruleRequiresValues(config, new Set());
|
||||
});
|
||||
console.log(`_ruleRequiresValues: ${(needsValues.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Handler.evaluate directly
|
||||
const handler = ruleEval.ruleHandlers.relational_comparator;
|
||||
const handlerEval = measure('handler.evaluate (direct)', (idx) => {
|
||||
const userId = arbiter.nodeIdByKey.get(`user:${idx}`);
|
||||
const objectId = arbiter.nodeIdByKey.get(`resource:${idx}`);
|
||||
handler.evaluate(userId, `user:${idx}`, objectId, `resource:${idx}`,
|
||||
config, new Set(), 'risk_ok_owa', { fastPath: true });
|
||||
});
|
||||
console.log(`handler.evaluate: ${(handlerEval.perOp * 1000).toFixed(3)}µs`);
|
||||
|
||||
// Check _ruleRequiresValues deeply
|
||||
console.log('\n=== _ruleRequiresValues Analysis ===\n');
|
||||
// This recursively checks the rule tree
|
||||
// For relational_comparator, it returns true immediately
|
||||
// But does it actually traverse?
|
||||
const visited2 = new Set();
|
||||
const start = process.hrtime.bigint();
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
visited2.clear();
|
||||
ruleEval._ruleRequiresValues(config, visited2);
|
||||
}
|
||||
const requiresMs = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
console.log(`_ruleRequiresValues (10k iterations): ${requiresMs.toFixed(2)}ms`);
|
||||
console.log(`Per call: ${(requiresMs / 10).toFixed(3)}µs`);
|
||||
|
||||
// Actually look at what the method does for relational_comparator
|
||||
console.log(`\nrule.type = '${config.type}'`);
|
||||
console.log(`_ruleRequiresValues returns true immediately for relational_comparator`);
|
||||
Reference in New Issue
Block a user