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,814 @@
|
||||
import { Arbiter } from '../src/core/Arbiter.js';
|
||||
import { OWAFusion } from '../src/utils/OWAFusion.js';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = new Map();
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const value = argv[i];
|
||||
if (!value.startsWith('--')) continue;
|
||||
if (value.startsWith('--no-')) {
|
||||
const key = value.slice(5);
|
||||
args.set(key, false);
|
||||
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;
|
||||
}
|
||||
|
||||
function createRng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (1664525 * state + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function parseSizes(value) {
|
||||
if (!value) return null;
|
||||
return value.split(',').map(item => Number(item.trim())).filter(Number.isFinite);
|
||||
}
|
||||
|
||||
function percentile(sorted, p) {
|
||||
if (sorted.length === 0) return 0;
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
function randInt(rng, max) {
|
||||
return Math.floor(rng() * max);
|
||||
}
|
||||
|
||||
function randRange(rng, min, max) {
|
||||
return min + Math.floor(rng() * (max - min));
|
||||
}
|
||||
|
||||
function buildUsers(arbiter, count, prefix) {
|
||||
const ids = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
ids.push(arbiter.addNode(`${prefix}:${i}`, prefix));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function setupOwaUnionScenario(arbiter, size, rng, relationName, unionConfig) {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
const trueIds = [];
|
||||
const falseIds = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (i % 3 === 0) {
|
||||
arbiter.addRelation(`user:${i}`, 'viewer', `resource:${i}`, 0.6);
|
||||
}
|
||||
if (i % 5 === 0) {
|
||||
arbiter.addRelation(`user:${i}`, 'owner', `resource:${i}`, 0.9);
|
||||
}
|
||||
if (i % 3 === 0 || i % 5 === 0) {
|
||||
trueIds.push(i);
|
||||
} else {
|
||||
falseIds.push(i);
|
||||
}
|
||||
}
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
arbiter.setRelationConfig(relationName, {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'viewer' },
|
||||
{ type: 'direct', relation: 'owner' }
|
||||
],
|
||||
...unionConfig
|
||||
}
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const userId = trueIds.length > 0 ? trueIds[randInt(rng, trueIds.length)] : 0;
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
},
|
||||
() => {
|
||||
const userId = falseIds.length > 0 ? falseIds[randInt(rng, falseIds.length)] : 0;
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
|
||||
function createQueries(count, pickTrue, pickFalse) {
|
||||
const queries = [];
|
||||
const half = Math.floor(count / 2);
|
||||
for (let i = 0; i < half; i++) {
|
||||
queries.push({ ...pickTrue(), expected: true });
|
||||
}
|
||||
for (let i = 0; i < count - half; i++) {
|
||||
queries.push({ ...pickFalse(), expected: false });
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
function createChallengeQueries(count, pickTrue, pickFalse) {
|
||||
const queries = [];
|
||||
const half = Math.floor(count / 2);
|
||||
for (let i = 0; i < half; i++) {
|
||||
const entry = pickTrue();
|
||||
queries.push({ ...entry, expected: true });
|
||||
}
|
||||
for (let i = 0; i < count - half; i++) {
|
||||
const entry = pickFalse();
|
||||
queries.push({ ...entry, expected: false });
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
function shuffle(items) {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function shuffleWithRng(items, rng) {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'owner_direct',
|
||||
relation: 'owner',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'owner', `resource:${i}`);
|
||||
}
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size * 2),
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return { user: `user:${id}`, object: `resource:${id}` };
|
||||
},
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return { user: `user:${id}`, object: `resource:${(id + 1) % size}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'viewer_direct',
|
||||
relation: 'viewer',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'viewer', `resource:${i}`);
|
||||
}
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size * 2),
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return { user: `user:${id}`, object: `resource:${id}` };
|
||||
},
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return { user: `user:${id}`, object: `resource:${(id + 1) % size}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'group_tuple_to_userset',
|
||||
relation: 'can_view',
|
||||
setup: (arbiter, size, rng) => {
|
||||
const groupCount = Math.max(1, Math.floor(size / 10));
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, groupCount, 'group');
|
||||
buildUsers(arbiter, groupCount, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
const groupId = i % groupCount;
|
||||
arbiter.addRelation(`user:${i}`, 'member', `group:${groupId}`);
|
||||
}
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
arbiter.addRelation(`group:${i}`, 'group_access', `resource:${i}`);
|
||||
}
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('group_access', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view', {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: 'group_access',
|
||||
tuplesetDirection: 'in',
|
||||
computedRelation: 'member'
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const groupId = userId % groupCount;
|
||||
return { user: `user:${userId}`, object: `resource:${groupId}` };
|
||||
},
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const groupId = (userId + 1) % groupCount;
|
||||
return { user: `user:${userId}`, object: `resource:${groupId}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plan_chain',
|
||||
relation: 'can_use',
|
||||
setup: (arbiter, size, rng) => {
|
||||
const planCount = Math.max(1, Math.floor(size / 10));
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, planCount, 'plan');
|
||||
buildUsers(arbiter, planCount, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
const planId = i % planCount;
|
||||
arbiter.addRelation(`user:${i}`, 'has_plan', `plan:${planId}`);
|
||||
}
|
||||
for (let i = 0; i < planCount; i++) {
|
||||
arbiter.addRelation(`plan:${i}`, 'plan_grants', `resource:${i}`);
|
||||
}
|
||||
arbiter.setRelationConfig('has_plan', { type: 'direct' });
|
||||
arbiter.setRelationConfig('plan_grants', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_use', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'has_plan', direction: 'out' },
|
||||
{ relation: 'plan_grants', direction: 'out' }
|
||||
],
|
||||
collectValues: false
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const planId = userId % planCount;
|
||||
return { user: `user:${userId}`, object: `resource:${planId}` };
|
||||
},
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const planId = (userId + 1) % planCount;
|
||||
return { user: `user:${userId}`, object: `resource:${planId}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'account_admin_chain',
|
||||
relation: 'can_admin',
|
||||
setup: (arbiter, size, rng) => {
|
||||
const accountCount = Math.max(1, Math.floor(size / 10));
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, accountCount, 'account');
|
||||
buildUsers(arbiter, accountCount, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
const accountId = i % accountCount;
|
||||
arbiter.addRelation(`user:${i}`, 'admin_of', `account:${accountId}`);
|
||||
}
|
||||
for (let i = 0; i < accountCount; i++) {
|
||||
arbiter.addRelation(`account:${i}`, 'account_owns', `resource:${i}`);
|
||||
}
|
||||
arbiter.setRelationConfig('admin_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('account_owns', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_admin', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'admin_of', direction: 'out' },
|
||||
{ relation: 'account_owns', direction: 'out' }
|
||||
],
|
||||
collectValues: false
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const accountId = userId % accountCount;
|
||||
return { user: `user:${userId}`, object: `resource:${accountId}` };
|
||||
},
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const accountId = (userId + 1) % accountCount;
|
||||
return { user: `user:${userId}`, object: `resource:${accountId}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'blocked_unless',
|
||||
relation: 'can_view_blocked',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'viewer', `resource:${i}`);
|
||||
}
|
||||
for (let i = 0; i < Math.floor(size / 4); i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'blocked', `resource:${i}`);
|
||||
}
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('blocked', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view_blocked', {
|
||||
exclusion: [
|
||||
{ type: 'direct', relation: 'viewer' },
|
||||
{ type: 'direct', relation: 'blocked' }
|
||||
]
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const blockedRange = Math.floor(size / 4);
|
||||
const userId = randRange(rng, blockedRange, size);
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
},
|
||||
() => {
|
||||
const userId = randInt(rng, Math.floor(size / 4));
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'age_abac',
|
||||
relation: 'age_ok',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'age', `user:${i}`, 1.0, { value: 18 + (i % 30) });
|
||||
arbiter.addRelation(`resource:${i}`, 'min_age', `resource:${i}`, 1.0, { value: 18 + (i % 15) });
|
||||
}
|
||||
arbiter.setRelationConfig('age', { type: 'direct' });
|
||||
arbiter.setRelationConfig('min_age', { type: 'direct' });
|
||||
arbiter.setRelationConfig('age_ok', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'age' },
|
||||
extractValue: true,
|
||||
valueRelation: 'age'
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'min_age' },
|
||||
extractValue: true,
|
||||
valueRelation: 'min_age',
|
||||
evaluateFrom: 'object'
|
||||
}
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const resourceId = userId % size;
|
||||
return { user: `user:${userId}`, object: `resource:${resourceId}` };
|
||||
},
|
||||
() => {
|
||||
const userId = randInt(rng, size);
|
||||
const resourceId = (userId + Math.floor(size / 2)) % size;
|
||||
return { user: `user:${userId}`, object: `resource:${resourceId}` };
|
||||
}
|
||||
);
|
||||
const withExpected = queries.map(q => {
|
||||
const userId = Number(q.user.split(':')[1]);
|
||||
const resourceId = Number(q.object.split(':')[1]);
|
||||
const age = 18 + (userId % 30);
|
||||
const minAge = 18 + (resourceId % 15);
|
||||
return { ...q, expected: age >= minAge };
|
||||
});
|
||||
return { queries: shuffleWithRng(withExpected, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'risk_limit',
|
||||
relation: 'risk_ok',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
const score = i % 100;
|
||||
arbiter.addRelation(`user:${i}`, 'risk_score', `resource:${i}`, 1.0, { value: score });
|
||||
arbiter.addRelation(`resource:${i}`, 'risk_limit', `resource:${i}`, 1.0, { value: 50 });
|
||||
}
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_ok', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'risk_score' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_score'
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'risk_limit' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'object'
|
||||
}
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const userId = randInt(rng, Math.min(size, 51));
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
},
|
||||
() => {
|
||||
const base = Math.min(size - 51, 49);
|
||||
const userId = base + randInt(rng, Math.max(1, size - base));
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
}
|
||||
);
|
||||
const withExpected = queries.map(q => {
|
||||
const userId = Number(q.user.split(':')[1]);
|
||||
const score = userId % 100;
|
||||
return { ...q, expected: score <= 50 };
|
||||
});
|
||||
return { queries: shuffleWithRng(withExpected, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feature_flag',
|
||||
relation: 'can_feature',
|
||||
setup: (arbiter, size, rng) => {
|
||||
const flagCount = Math.max(1, Math.floor(size / 10));
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
buildUsers(arbiter, flagCount, 'flag');
|
||||
for (let i = 0; i < size; i++) {
|
||||
const flagId = i % flagCount;
|
||||
arbiter.addRelation(`user:${i}`, 'has_flag', `flag:${flagId}`);
|
||||
if (i % 2 === 0) {
|
||||
arbiter.addRelation(`resource:${i}`, 'feature_flag', `flag:${flagId}`);
|
||||
}
|
||||
}
|
||||
arbiter.setRelationConfig('has_flag', { type: 'direct' });
|
||||
arbiter.setRelationConfig('feature_flag', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_feature', {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: 'feature_flag',
|
||||
tuplesetDirection: 'out',
|
||||
computedRelation: 'has_flag'
|
||||
});
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => {
|
||||
const userId = randInt(rng, Math.floor(size / 2)) * 2;
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
},
|
||||
() => {
|
||||
const userId = randInt(rng, Math.floor(size / 2)) * 2 + 1;
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
}
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
}
|
||||
,{
|
||||
name: 'owa_union',
|
||||
relation: 'can_view_owa',
|
||||
setup: (arbiter, size, rng) => {
|
||||
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa', {
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.7, 0.3]
|
||||
});
|
||||
}
|
||||
}
|
||||
,{
|
||||
name: 'owa_union_max',
|
||||
relation: 'can_view_owa_max',
|
||||
setup: (arbiter, size, rng) => {
|
||||
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_max', {
|
||||
aggregator: 'max'
|
||||
});
|
||||
}
|
||||
}
|
||||
,{
|
||||
name: 'owa_union_min',
|
||||
relation: 'can_view_owa_min',
|
||||
setup: (arbiter, size, rng) => {
|
||||
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_min', {
|
||||
aggregator: 'min'
|
||||
});
|
||||
}
|
||||
}
|
||||
,{
|
||||
name: 'owa_union_top2',
|
||||
relation: 'can_view_owa_top2',
|
||||
setup: (arbiter, size, rng) => {
|
||||
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_top2', {
|
||||
aggregator: 'top2'
|
||||
});
|
||||
}
|
||||
}
|
||||
,{
|
||||
name: 'owa_union_median',
|
||||
relation: 'can_view_owa_median',
|
||||
setup: (arbiter, size, rng) => {
|
||||
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_median', {
|
||||
aggregator: 'median'
|
||||
});
|
||||
}
|
||||
}
|
||||
,{
|
||||
name: 'owa_comparator_nested',
|
||||
relation: 'risk_ok_owa',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
const trueIds = [];
|
||||
const falseIds = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
const isSafe = i % 2 === 0;
|
||||
const score = isSafe ? 20 : 80;
|
||||
const bonus = isSafe ? 5 : 15;
|
||||
const noise = isSafe ? 0 : 10;
|
||||
arbiter.addRelation(`user:${i}`, 'risk_score', `resource:${i}`, 1.0, { value: score });
|
||||
arbiter.addRelation(`user:${i}`, 'risk_bonus', `resource:${i}`, 1.0, { value: bonus });
|
||||
arbiter.addRelation(`user:${i}`, 'risk_noise', `resource:${i}`, 1.0, { value: noise });
|
||||
arbiter.addRelation(`resource:${i}`, 'risk_limit', `resource:${i}`, 1.0, { value: 40 });
|
||||
arbiter.addRelation(`resource:${i}`, 'risk_cap', `resource:${i}`, 1.0, { value: 45 });
|
||||
if (isSafe) {
|
||||
trueIds.push(i);
|
||||
} else {
|
||||
falseIds.push(i);
|
||||
}
|
||||
}
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_bonus', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_noise', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_cap', { 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]
|
||||
}
|
||||
});
|
||||
const computeExpected = (userId, resourceId) => {
|
||||
const valuesLeft = [
|
||||
userId % 2 === 0 ? 20 : 80,
|
||||
userId % 2 === 0 ? 5 : 15,
|
||||
userId % 2 === 0 ? 0 : 10
|
||||
];
|
||||
const leftIntervals = valuesLeft.map(value => ({ min: value, max: value }));
|
||||
const leftMeta = valuesLeft.map(() => ({ ruleType: 'defeasible', rule: {} }));
|
||||
const leftFused = OWAFusion.fuseIntervalsWithMeta(leftIntervals, leftMeta, [0.5, 0.3, 0.2], 'owa');
|
||||
const rightValues = [40, 45];
|
||||
const rightIntervals = rightValues.map(value => ({ min: value, max: value }));
|
||||
const rightMeta = rightValues.map(() => ({ ruleType: 'defeasible', rule: {} }));
|
||||
const rightFused = OWAFusion.fuseIntervalsWithMeta(rightIntervals, rightMeta, [0.6, 0.4], 'owa');
|
||||
return leftFused.interval.min <= rightFused.interval.min;
|
||||
};
|
||||
const pickWithExpectation = (expected) => {
|
||||
const pool = expected ? trueIds : falseIds;
|
||||
if (pool.length === 0) {
|
||||
const userId = randInt(rng, size);
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
}
|
||||
const userId = pool[randInt(rng, pool.length)];
|
||||
return { user: `user:${userId}`, object: `resource:${userId}` };
|
||||
};
|
||||
const queries = createQueries(
|
||||
Math.min(2000, size),
|
||||
() => pickWithExpectation(true),
|
||||
() => pickWithExpectation(false)
|
||||
);
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'challenge_mfa_within',
|
||||
relation: 'secure_action',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
arbiter.setRelationConfig('secure_action', {
|
||||
type: 'challenge',
|
||||
challenge: 'mfa',
|
||||
subject: 'user',
|
||||
withinMs: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const queries = createChallengeQueries(
|
||||
Math.min(2000, size * 2),
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return {
|
||||
user: `user:${id}`,
|
||||
object: `resource:${id}`,
|
||||
options: {
|
||||
partialGraph: {
|
||||
challenges: [
|
||||
{ name: 'mfa', subject: `user:${id}`, issuedAt: now }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return {
|
||||
user: `user:${id}`,
|
||||
object: `resource:${id}`,
|
||||
options: {
|
||||
partialGraph: {
|
||||
challenges: [
|
||||
{ name: 'mfa', subject: `user:${id}`, issuedAt: now - 10 * 60 * 1000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'challenge_composed_union',
|
||||
relation: 'can_delete',
|
||||
setup: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
arbiter.setRelationConfig('secure_connection', {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'challenge', challenge: 'mfa', subject: 'user', withinMs: 5 * 60 * 1000 },
|
||||
{ type: 'challenge', challenge: 'webauthn', subject: 'user', withinMs: 5 * 60 * 1000 }
|
||||
]
|
||||
}
|
||||
});
|
||||
arbiter.setRelationConfig('can_delete', { type: 'computed', relation: 'secure_connection' });
|
||||
|
||||
const now = Date.now();
|
||||
const queries = createChallengeQueries(
|
||||
Math.min(2000, size * 2),
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return {
|
||||
user: `user:${id}`,
|
||||
object: `resource:${id}`,
|
||||
options: {
|
||||
partialGraph: {
|
||||
challenges: [
|
||||
{ name: 'mfa', subject: `user:${id}`, issuedAt: now }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
() => {
|
||||
const id = randInt(rng, size);
|
||||
return {
|
||||
user: `user:${id}`,
|
||||
object: `resource:${id}`,
|
||||
options: {
|
||||
partialGraph: {
|
||||
challenges: []
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { queries: shuffleWithRng(queries, rng) };
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
const config = {
|
||||
sizes: parseSizes(args.get('sizes')) || [1000, 5000, 10000],
|
||||
samples: Number(args.get('samples') || 2000),
|
||||
seed: Number(args.get('seed') || 42),
|
||||
maxSeconds: Number(args.get('max-seconds') || 60),
|
||||
verify: args.has('verify'),
|
||||
snapshot: args.has('snapshot')
|
||||
};
|
||||
|
||||
const rng = createRng(config.seed);
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log('b2c_auth_bench');
|
||||
console.log('scenario,nodes,edges,build_ms,auth_ms,auth_qps,precision,recall,f1,p95_ms,p99_ms');
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
for (const size of config.sizes) {
|
||||
if ((Date.now() - startTime) / 1000 > config.maxSeconds) break;
|
||||
const arbiter = new Arbiter();
|
||||
const buildStart = Date.now();
|
||||
const { queries } = scenario.setup(arbiter, size, rng);
|
||||
let buildMs = Date.now() - buildStart;
|
||||
|
||||
if (config.snapshot) {
|
||||
const snapshotStart = Date.now();
|
||||
arbiter.enableCondensedSnapshot();
|
||||
buildMs += Date.now() - snapshotStart;
|
||||
}
|
||||
|
||||
const actualQueries = queries.slice(0, config.samples);
|
||||
let tp = 0;
|
||||
let tn = 0;
|
||||
let fp = 0;
|
||||
let fn = 0;
|
||||
const latencies = [];
|
||||
const authStart = process.hrtime.bigint();
|
||||
for (const query of actualQueries) {
|
||||
const start = process.hrtime.bigint();
|
||||
const options = query.options ? { fastPath: true, ...query.options } : { fastPath: true };
|
||||
const result = arbiter.check(query.user, scenario.relation, query.object, options);
|
||||
const predicted = result && result.possibility > 0;
|
||||
if (predicted && query.expected) tp++;
|
||||
else if (!predicted && !query.expected) tn++;
|
||||
else if (predicted && !query.expected) fp++;
|
||||
else if (!predicted && query.expected) fn++;
|
||||
latencies.push(Number(process.hrtime.bigint() - start) / 1e6);
|
||||
}
|
||||
const authMs = Number(process.hrtime.bigint() - authStart) / 1e6;
|
||||
const authQps = authMs > 0 ? Math.round((actualQueries.length / authMs) * 1000) : 0;
|
||||
const precision = tp + fp > 0 ? tp / (tp + fp) : 0;
|
||||
const recall = tp + fn > 0 ? tp / (tp + fn) : 0;
|
||||
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
||||
const sorted = latencies.sort((a, b) => a - b);
|
||||
const p95 = percentile(sorted, 95).toFixed(3);
|
||||
const p99 = percentile(sorted, 99).toFixed(3);
|
||||
|
||||
console.log([
|
||||
scenario.name,
|
||||
size,
|
||||
arbiter.relations.length,
|
||||
buildMs,
|
||||
Math.round(authMs),
|
||||
authQps,
|
||||
precision.toFixed(3),
|
||||
recall.toFixed(3),
|
||||
f1.toFixed(3),
|
||||
p95,
|
||||
p99
|
||||
].join(','));
|
||||
|
||||
if (config.verify && (fp + fn) > 0) {
|
||||
throw new Error(`Verification failed for ${scenario.name} size ${size}: fp=${fp} fn=${fn}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user