717ae1031e
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.
330 lines
12 KiB
JavaScript
330 lines
12 KiB
JavaScript
import { Arbiter } from '../src/core/Arbiter.js';
|
|
import fs from 'node:fs';
|
|
import { performance } from 'node:perf_hooks';
|
|
|
|
function parseArgs(argv) {
|
|
const args = new Map();
|
|
for (let i = 2; i < argv.length; i++) {
|
|
const value = argv[i];
|
|
if (!value.startsWith('--')) continue;
|
|
const [key, inline] = value.slice(2).split('=');
|
|
if (inline !== undefined) {
|
|
args.set(key, inline);
|
|
continue;
|
|
}
|
|
const next = argv[i + 1];
|
|
if (next && !next.startsWith('--')) {
|
|
args.set(key, next);
|
|
i++;
|
|
} else {
|
|
args.set(key, true);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
const args = parseArgs(process.argv);
|
|
const edges = Number(args.get('edges') || 200000);
|
|
const users = Number(args.get('users') || 10000);
|
|
const docs = Number(args.get('docs') || 50000);
|
|
const groups = Number(args.get('groups') || 500);
|
|
const samples = Number(args.get('samples') || 5000);
|
|
const snapshotPath = args.get('snapshot-path') || null;
|
|
const debug = args.has('debug');
|
|
const fastConstruction = args.has('no-fast') ? false : true;
|
|
const ensureHits = args.has('ensure-hits');
|
|
const fixedPair = ensureHits;
|
|
const buildIndices = args.has('no-indices') ? false : true;
|
|
const hitPairs = [];
|
|
|
|
console.log('Arbiter snapshot boot bench');
|
|
console.log(` edges: ${edges}`);
|
|
console.log(` users: ${users}`);
|
|
console.log(` docs: ${docs}`);
|
|
console.log(` groups: ${groups}`);
|
|
console.log(` samples: ${samples}`);
|
|
if (snapshotPath) {
|
|
console.log(` snapshot path: ${snapshotPath}`);
|
|
}
|
|
console.log(` fast construction: ${fastConstruction}`);
|
|
console.log(` ensure hits: ${ensureHits}`);
|
|
console.log(` fixed pair: ${fixedPair}`);
|
|
console.log(` build indices: ${buildIndices}`);
|
|
|
|
let snapshotBuffer = null;
|
|
|
|
if (snapshotPath && fs.existsSync(snapshotPath)) {
|
|
snapshotBuffer = fs.readFileSync(snapshotPath).buffer;
|
|
}
|
|
|
|
let buildTime = 0;
|
|
let snapshotTime = 0;
|
|
|
|
if (!snapshotBuffer) {
|
|
const arbiter = new Arbiter({
|
|
fastConstructionMode: fastConstruction,
|
|
disableCaching: fastConstruction
|
|
});
|
|
|
|
if (debug) console.log(' building nodes...');
|
|
const nodeStart = performance.now();
|
|
for (let i = 0; i < users; i++) {
|
|
arbiter.addNode(`user:${i}`, 'user');
|
|
}
|
|
for (let i = 0; i < docs; i++) {
|
|
arbiter.addNode(`doc:${i}`, 'doc');
|
|
}
|
|
for (let i = 0; i < groups; i++) {
|
|
arbiter.addNode(`group:${i}`, 'group');
|
|
}
|
|
const nodeTime = performance.now() - nodeStart;
|
|
if (debug) console.log(` nodes ready (${nodeTime.toFixed(2)} ms)`);
|
|
|
|
if (debug) console.log(' building relations...');
|
|
const buildStart = performance.now();
|
|
for (let i = 0; i < edges; i++) {
|
|
const user = `user:${i % users}`;
|
|
const doc = `doc:${i % docs}`;
|
|
const group = `group:${i % groups}`;
|
|
const rel = ['owner', 'editor', 'viewer'][i % 3];
|
|
|
|
if (i % 5 === 0) {
|
|
arbiter.addRelation(user, 'member', group);
|
|
}
|
|
if (i % 7 === 0) {
|
|
arbiter.addRelation(group, 'viewer', doc);
|
|
}
|
|
arbiter.addRelation(user, rel, doc);
|
|
}
|
|
buildTime = performance.now() - buildStart;
|
|
if (debug) console.log(` relations ready (${buildTime.toFixed(2)} ms)`);
|
|
|
|
if (debug) console.log(' compiling rules...');
|
|
arbiter.setRelationConfig('member', { type: 'direct' });
|
|
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
|
arbiter.setRelationConfig('risk', { type: 'direct' });
|
|
arbiter.setRelationConfig('threshold', { type: 'direct' });
|
|
|
|
arbiter.setRelationConfig('can_view', {
|
|
union: {
|
|
rules: [
|
|
{ type: 'direct', relation: 'owner' },
|
|
{ type: 'direct', relation: 'editor' },
|
|
{ type: 'tuple_to_userset', tuplesetRelation: 'viewer', tuplesetDirection: 'in', computedRelation: 'member' }
|
|
]
|
|
}
|
|
});
|
|
|
|
arbiter.setRelationConfig('complex_view', {
|
|
intersection: {
|
|
rules: [
|
|
{ type: 'tuple_to_userset', tuplesetRelation: 'viewer', tuplesetDirection: 'in', computedRelation: 'member' },
|
|
{
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member', direction: 'out' },
|
|
{ relation: 'viewer', direction: 'out' }
|
|
]
|
|
},
|
|
{
|
|
type: 'relational_comparator',
|
|
comparator: '>=',
|
|
left: {
|
|
rule: { type: 'direct', relation: 'risk' },
|
|
valueRelation: 'risk',
|
|
aggregator: 'max',
|
|
evaluateFrom: 'auto'
|
|
},
|
|
right: {
|
|
rule: { type: 'direct', relation: 'threshold' },
|
|
valueRelation: 'threshold',
|
|
aggregator: 'max',
|
|
evaluateFrom: 'auto'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
});
|
|
|
|
if (!ensureHits) {
|
|
for (let i = 0; i < Math.min(20000, edges); i++) {
|
|
arbiter.addRelation(`user:${i % users}`, 'risk', `doc:${i % docs}`, { value: (i % 100) / 100 });
|
|
arbiter.addRelation(`user:${i % users}`, 'threshold', `doc:${i % docs}`, { value: 0.5 });
|
|
}
|
|
}
|
|
|
|
if (ensureHits) {
|
|
const hitCount = Math.min(200, users, docs, groups);
|
|
for (let i = 0; i < hitCount; i++) {
|
|
const user = `user:${i}`;
|
|
const doc = `doc:${i}`;
|
|
const group = `group:${i}`;
|
|
arbiter.addRelation(user, 'member', group);
|
|
arbiter.addRelation(group, 'viewer', doc);
|
|
arbiter.addRelation(user, 'risk', doc, { value: 0.9 });
|
|
arbiter.addRelation(user, 'threshold', doc, { value: 0.1 });
|
|
hitPairs.push({ user, doc });
|
|
}
|
|
}
|
|
|
|
if (debug) console.log(' snapshotting...');
|
|
const snapshotStart = performance.now();
|
|
snapshotBuffer = arbiter.toSnapshotBinary({ dropAdjacencyList: true });
|
|
snapshotTime = performance.now() - snapshotStart;
|
|
if (debug) console.log(` snapshot ready (${snapshotTime.toFixed(2)} ms)`);
|
|
|
|
console.log(` node build time: ${nodeTime.toFixed(2)} ms`);
|
|
}
|
|
|
|
const snapshotSize = snapshotBuffer.byteLength;
|
|
|
|
if (snapshotPath && !fs.existsSync(snapshotPath)) {
|
|
fs.writeFileSync(snapshotPath, new Uint8Array(snapshotBuffer));
|
|
}
|
|
|
|
const memBefore = process.memoryUsage();
|
|
const loadStart = performance.now();
|
|
const booted = Arbiter.fromSnapshotBinary(snapshotBuffer, { buildSnapshotIndices: buildIndices });
|
|
const loadTime = performance.now() - loadStart;
|
|
const memAfter = process.memoryUsage();
|
|
|
|
const usersArr = Array.from({ length: users }, (_, i) => `user:${i}`);
|
|
const docsArr = Array.from({ length: docs }, (_, i) => `doc:${i}`);
|
|
let effectiveHitPairs = hitPairs;
|
|
if (ensureHits && effectiveHitPairs.length === 0) {
|
|
effectiveHitPairs = [{ user: 'user:0', doc: 'doc:0' }];
|
|
}
|
|
|
|
const hitUsers = ensureHits
|
|
? (fixedPair ? [effectiveHitPairs[0].user] : effectiveHitPairs.map(pair => pair.user))
|
|
: usersArr;
|
|
const hitDocs = ensureHits
|
|
? (fixedPair ? [effectiveHitPairs[0].doc] : effectiveHitPairs.map(pair => pair.doc))
|
|
: docsArr;
|
|
const hitUserIds = hitUsers.map(key => booted.resolveNodeId(key));
|
|
const hitDocIds = hitDocs.map(key => booted.resolveNodeId(key));
|
|
const complexConfig = booted.relationConfigs.get('complex_view');
|
|
|
|
const simpleStart = performance.now();
|
|
let simpleHits = 0;
|
|
for (let i = 0; i < samples; i++) {
|
|
const user = hitUsers[i % hitUsers.length];
|
|
const doc = hitDocs[i % hitDocs.length];
|
|
const result = booted.check(user, 'can_view', doc, { fastPath: true });
|
|
if (result?.possibility > 0) simpleHits++;
|
|
}
|
|
const simpleTime = performance.now() - simpleStart;
|
|
|
|
const complexStart = performance.now();
|
|
let complexHits = 0;
|
|
for (let i = 0; i < samples; i++) {
|
|
const idx = i % hitUsers.length;
|
|
const user = hitUsers[idx];
|
|
const doc = hitDocs[idx];
|
|
const userId = hitUserIds[idx];
|
|
const docId = hitDocIds[idx];
|
|
const result = booted.authChecker.ruleEvaluator.evaluateRule(
|
|
userId,
|
|
user,
|
|
docId,
|
|
doc,
|
|
complexConfig,
|
|
new Set(),
|
|
'complex_view',
|
|
{ fastPath: false, collectValues: true, cacheRuleResult: false }
|
|
);
|
|
if (result?.possibility > 0) complexHits++;
|
|
}
|
|
const complexTime = performance.now() - complexStart;
|
|
|
|
console.log('\nResults');
|
|
console.log(` build time: ${buildTime.toFixed(2)} ms`);
|
|
console.log(` snapshot time: ${snapshotTime.toFixed(2)} ms`);
|
|
console.log(` snapshot size: ${(snapshotSize / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(` load time: ${loadTime.toFixed(2)} ms`);
|
|
console.log(` memory rss before: ${(memBefore.rss / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(` memory rss after: ${(memAfter.rss / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(` memory heap before: ${(memBefore.heapUsed / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(` memory heap after: ${(memAfter.heapUsed / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(` simple avg: ${(simpleTime / samples * 1000).toFixed(3)} µs`);
|
|
console.log(` complex avg: ${(complexTime / samples * 1000).toFixed(3)} µs`);
|
|
console.log(` simple hits: ${simpleHits}`);
|
|
console.log(` complex hits: ${complexHits}`);
|
|
if (ensureHits && debug) {
|
|
const sampleUser = hitUsers[0];
|
|
const sampleDoc = hitDocs[0];
|
|
const sampleSimple = booted.check(sampleUser, 'can_view', sampleDoc, { fastPath: true });
|
|
const sampleComplex = booted.check(sampleUser, 'complex_view', sampleDoc, { fastPath: false, collectValues: true, includeMeta: true });
|
|
console.log(` sample can_view possibility: ${sampleSimple?.possibility ?? 'null'}`);
|
|
console.log(` sample complex_view possibility: ${sampleComplex?.possibility ?? 'null'}`);
|
|
if (sampleComplex?.meta) {
|
|
console.log(` sample complex meta: ${JSON.stringify(sampleComplex.meta)}`);
|
|
}
|
|
|
|
const complexConfig = booted.relationConfigs.get('complex_view');
|
|
|
|
const userId = booted.resolveNodeId(sampleUser);
|
|
const objectId = booted.resolveNodeId(sampleDoc);
|
|
if (userId !== undefined && objectId !== undefined) {
|
|
const evaluator = booted.authChecker.ruleEvaluator;
|
|
const visited = new Set();
|
|
const tupleRule = { type: 'tuple_to_userset', tuplesetRelation: 'viewer', tuplesetDirection: 'in', computedRelation: 'member' };
|
|
const chainRule = {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member', direction: 'out' },
|
|
{ relation: 'viewer', direction: 'out' }
|
|
]
|
|
};
|
|
const compRule = {
|
|
type: 'relational_comparator',
|
|
comparator: '>=',
|
|
left: {
|
|
rule: { type: 'direct', relation: 'risk' },
|
|
valueRelation: 'risk',
|
|
aggregator: 'max',
|
|
evaluateFrom: 'auto'
|
|
},
|
|
right: {
|
|
rule: { type: 'direct', relation: 'threshold' },
|
|
valueRelation: 'threshold',
|
|
aggregator: 'max',
|
|
evaluateFrom: 'auto'
|
|
}
|
|
};
|
|
const tupleResult = evaluator.evaluateRule(userId, sampleUser, objectId, sampleDoc, tupleRule, visited, 'tuple_debug', { fastPath: false, collectValues: true });
|
|
const chainResult = evaluator.evaluateRule(userId, sampleUser, objectId, sampleDoc, chainRule, visited, 'chain_debug', { fastPath: false, collectValues: true });
|
|
const compResult = evaluator.evaluateRule(userId, sampleUser, objectId, sampleDoc, compRule, visited, 'comp_debug', { fastPath: false, collectValues: true, includeMeta: true });
|
|
console.log(` tuple_to_userset possibility: ${tupleResult?.possibility ?? 'null'}`);
|
|
console.log(` chain possibility: ${chainResult?.possibility ?? 'null'}`);
|
|
console.log(` comparator possibility: ${compResult?.possibility ?? 'null'}`);
|
|
if (compResult?.meta) {
|
|
console.log(` comparator meta: ${JSON.stringify(compResult.meta)}`);
|
|
}
|
|
const riskRel = booted.relationManager.getDirectRelation(userId, 'risk', objectId);
|
|
const thresholdRel = booted.relationManager.getDirectRelation(userId, 'threshold', objectId);
|
|
console.log(` risk relation value: ${riskRel?.value ?? 'null'}`);
|
|
console.log(` threshold relation value: ${thresholdRel?.value ?? 'null'}`);
|
|
|
|
if (complexConfig) {
|
|
const compiled = complexConfig._compiled;
|
|
if (compiled && compiled.type === 'logical') {
|
|
const childTypes = compiled.children.map(child => child?.type || 'null');
|
|
console.log(` complex compiled children: ${JSON.stringify(childTypes)}`);
|
|
|
|
const compiledEvaluator = evaluator.compiledEvaluator;
|
|
const childPoss = compiled.children.map(child => {
|
|
const res = compiledEvaluator.evaluate(child, userId, sampleUser, objectId, sampleDoc, new Set(), 'complex_child', { fastPath: false, collectValues: true, includeMeta: true });
|
|
return res?.possibility ?? null;
|
|
});
|
|
console.log(` complex compiled child possibilities: ${JSON.stringify(childPoss)}`);
|
|
}
|
|
const directComplex = evaluator.evaluateRule(userId, sampleUser, objectId, sampleDoc, complexConfig, new Set(), 'complex_debug', { fastPath: false, collectValues: true, includeMeta: true });
|
|
console.log(` direct complex possibility: ${directComplex?.possibility ?? 'null'}`);
|
|
if (directComplex?.meta) {
|
|
console.log(` direct complex meta: ${JSON.stringify(directComplex.meta)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|