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.
1314 lines
46 KiB
JavaScript
1314 lines
46 KiB
JavaScript
import { Arbiter } from '../src/core/Arbiter.js';
|
|
|
|
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;
|
|
}
|
|
|
|
function createRng(seed) {
|
|
if (seed === null || seed === undefined) {
|
|
return Math.random;
|
|
}
|
|
let state = seed >>> 0;
|
|
return () => {
|
|
state = (1664525 * state + 1013904223) >>> 0;
|
|
return state / 0x100000000;
|
|
};
|
|
}
|
|
|
|
function buildZipfSampler(size, skew, rng) {
|
|
const cumulative = new Array(size);
|
|
let sum = 0;
|
|
for (let i = 1; i <= size; i++) {
|
|
sum += 1 / Math.pow(i, skew);
|
|
cumulative[i - 1] = sum;
|
|
}
|
|
return () => {
|
|
const target = rng() * sum;
|
|
let low = 0;
|
|
let high = cumulative.length - 1;
|
|
while (low < high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
if (target <= cumulative[mid]) {
|
|
high = mid;
|
|
} else {
|
|
low = mid + 1;
|
|
}
|
|
}
|
|
return low;
|
|
};
|
|
}
|
|
|
|
function pickRandom(items, rng) {
|
|
const index = Math.floor(rng() * items.length);
|
|
return items[index];
|
|
}
|
|
|
|
function addToMapSet(map, key, value) {
|
|
if (!map.has(key)) {
|
|
map.set(key, new Set());
|
|
}
|
|
map.get(key).add(value);
|
|
}
|
|
|
|
function basicSimilarity(key1, key2) {
|
|
if (key1 === key2) return 1.0;
|
|
const type1 = key1.split(':')[0];
|
|
const type2 = key2.split(':')[0];
|
|
if (type1 === type2) {
|
|
const id1 = key1.split(':')[1] || '';
|
|
const id2 = key2.split(':')[1] || '';
|
|
const num1 = parseInt(id1, 10);
|
|
const num2 = parseInt(id2, 10);
|
|
if (!isNaN(num1) && !isNaN(num2)) {
|
|
const diff = Math.abs(num1 - num2);
|
|
if (diff === 0) return 1.0;
|
|
if (diff <= 5) return 0.8;
|
|
if (diff <= 20) return 0.6;
|
|
if (diff <= 100) return 0.4;
|
|
return 0.2;
|
|
}
|
|
const maxLen = Math.max(id1.length, id2.length);
|
|
const minLen = Math.min(id1.length, id2.length);
|
|
let matches = 0;
|
|
for (let i = 0; i < minLen; i++) {
|
|
if (id1[i] === id2[i]) matches++;
|
|
}
|
|
return 0.5 + (maxLen > 0 ? (matches / maxLen) * 0.3 : 0);
|
|
}
|
|
return 0.1;
|
|
}
|
|
|
|
function convertToQualitative(value, scale) {
|
|
let closestValue = scale[0];
|
|
let minDistance = Math.abs(value - scale[0]);
|
|
for (const scaleValue of scale) {
|
|
const distance = Math.abs(value - scaleValue);
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
closestValue = scaleValue;
|
|
}
|
|
}
|
|
return closestValue;
|
|
}
|
|
|
|
function estimateHitRate(frequencies, cacheSize) {
|
|
if (!frequencies.size || cacheSize <= 0) return 0;
|
|
const counts = Array.from(frequencies.values()).sort((a, b) => b - a);
|
|
let total = 0;
|
|
for (const count of counts) total += count;
|
|
let top = 0;
|
|
for (let i = 0; i < Math.min(cacheSize, counts.length); i++) {
|
|
top += counts[i];
|
|
}
|
|
return top / total;
|
|
}
|
|
|
|
function percentile(sortedValues, percentileValue) {
|
|
if (sortedValues.length === 0) return 0;
|
|
const idx = Math.min(
|
|
sortedValues.length - 1,
|
|
Math.max(0, Math.ceil((percentileValue / 100) * sortedValues.length) - 1)
|
|
);
|
|
return sortedValues[idx];
|
|
}
|
|
|
|
function buildGraph({
|
|
users,
|
|
groups,
|
|
resources,
|
|
memberPerUser,
|
|
accessPerGroup,
|
|
viewerPerUser,
|
|
ownerPerUser,
|
|
blockedPerUser,
|
|
riskPerUser,
|
|
linkPerUser,
|
|
linkPerResource,
|
|
linkReversePerResource,
|
|
riskLimitBase,
|
|
riskLimitVariance,
|
|
trustPerUser,
|
|
trustRequirementBase,
|
|
trustRequirementVariance,
|
|
rng
|
|
}) {
|
|
const arbiter = new Arbiter();
|
|
const userIds = [];
|
|
const groupIds = [];
|
|
const resourceIds = [];
|
|
|
|
for (let i = 0; i < users; i++) {
|
|
userIds.push(arbiter.addNode(`user:${i}`, 'user'));
|
|
}
|
|
for (let i = 0; i < groups; i++) {
|
|
groupIds.push(arbiter.addNode(`group:${i}`, 'group'));
|
|
}
|
|
for (let i = 0; i < resources; i++) {
|
|
resourceIds.push(arbiter.addNode(`resource:${i}`, 'resource'));
|
|
}
|
|
|
|
const viewerSet = new Set();
|
|
const ownerSet = new Set();
|
|
const blockedSet = new Set();
|
|
const memberKeySet = new Set();
|
|
const groupAccessKeySet = new Set();
|
|
const riskScoreMap = new Map();
|
|
const linkKeySet = new Set();
|
|
const resourceAccessKeySet = new Set();
|
|
|
|
const memberMap = new Map();
|
|
const groupAccessMap = new Map();
|
|
const viewerByResource = new Map();
|
|
const viewerByUser = new Map();
|
|
const linkAdjacency = new Map();
|
|
const linkReverseAdjacency = new Map();
|
|
const resourceAccessMap = new Map();
|
|
|
|
const getKey = (nodeId) => arbiter.keyByNodeId.get(nodeId);
|
|
const addDirectEdge = (relation, srcId, dstId, keySet, value) => {
|
|
const key = arbiter.keyManager.createCompositeKey(srcId, relation, dstId);
|
|
if (keySet.has(key)) return false;
|
|
keySet.add(key);
|
|
const srcKey = getKey(srcId);
|
|
const dstKey = getKey(dstId);
|
|
if (value !== undefined) {
|
|
arbiter.addRelation(srcKey, relation, dstKey, 1.0, { value });
|
|
riskScoreMap.set(key, value);
|
|
} else if (relation === 'member') {
|
|
arbiter.addRelation(srcKey, relation, dstKey, 1.0);
|
|
} else if (relation === 'group_access') {
|
|
arbiter.addRelation(srcKey, relation, dstKey, 1.0);
|
|
arbiter.addRelation(dstKey, 'resource_access', srcKey, 1.0);
|
|
addToMapSet(resourceAccessMap, dstId, srcId);
|
|
resourceAccessKeySet.add(
|
|
arbiter.keyManager.createCompositeKey(dstId, 'resource_access', srcId)
|
|
);
|
|
} else {
|
|
arbiter.addRelation(srcKey, relation, dstKey, 1.0);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const addLinkEdge = (srcId, dstId) => {
|
|
const srcKey = getKey(srcId);
|
|
const dstKey = getKey(dstId);
|
|
const key = arbiter.keyManager.createCompositeKey(srcId, 'link', dstId);
|
|
arbiter.addRelation(srcKey, 'link', dstKey, 1.0);
|
|
linkKeySet.add(key);
|
|
addToMapSet(linkAdjacency, srcId, dstId);
|
|
addToMapSet(linkReverseAdjacency, dstId, srcId);
|
|
return key;
|
|
};
|
|
|
|
for (const userId of userIds) {
|
|
const membershipCount = Math.max(1, Math.round(memberPerUser));
|
|
for (let i = 0; i < membershipCount; i++) {
|
|
const groupId = pickRandom(groupIds, rng);
|
|
const key = arbiter.keyManager.createCompositeKey(userId, 'member', groupId);
|
|
if (memberKeySet.has(key)) continue;
|
|
memberKeySet.add(key);
|
|
arbiter.addRelation(getKey(userId), 'member', getKey(groupId), 1.0);
|
|
addToMapSet(memberMap, userId, groupId);
|
|
}
|
|
}
|
|
|
|
for (const groupId of groupIds) {
|
|
const accessCount = Math.max(1, Math.round(accessPerGroup));
|
|
for (let i = 0; i < accessCount; i++) {
|
|
const resourceId = pickRandom(resourceIds, rng);
|
|
const key = arbiter.keyManager.createCompositeKey(groupId, 'group_access', resourceId);
|
|
if (groupAccessKeySet.has(key)) continue;
|
|
groupAccessKeySet.add(key);
|
|
arbiter.addRelation(getKey(groupId), 'group_access', getKey(resourceId), 1.0);
|
|
arbiter.addRelation(getKey(resourceId), 'resource_access', getKey(groupId), 1.0);
|
|
resourceAccessKeySet.add(
|
|
arbiter.keyManager.createCompositeKey(resourceId, 'resource_access', groupId)
|
|
);
|
|
addToMapSet(resourceAccessMap, resourceId, groupId);
|
|
addToMapSet(groupAccessMap, groupId, resourceId);
|
|
}
|
|
}
|
|
|
|
const addRandomUserResourceEdges = (relation, count, keySet) => {
|
|
let added = 0;
|
|
const maxAttempts = count * 3;
|
|
for (let attempt = 0; attempt < maxAttempts && added < count; attempt++) {
|
|
const userId = pickRandom(userIds, rng);
|
|
const resourceId = pickRandom(resourceIds, rng);
|
|
if (addDirectEdge(relation, userId, resourceId, keySet)) {
|
|
added++;
|
|
}
|
|
}
|
|
};
|
|
|
|
addRandomUserResourceEdges('viewer', Math.round(users * viewerPerUser), viewerSet);
|
|
addRandomUserResourceEdges('owner', Math.round(users * ownerPerUser), ownerSet);
|
|
addRandomUserResourceEdges('blocked', Math.round(users * blockedPerUser), blockedSet);
|
|
|
|
for (const key of viewerSet) {
|
|
const [userId, , resourceId] = JSON.parse(key);
|
|
addToMapSet(viewerByResource, resourceId, userId);
|
|
addToMapSet(viewerByUser, userId, resourceId);
|
|
}
|
|
|
|
const riskCount = Math.round(users * riskPerUser);
|
|
let riskAdded = 0;
|
|
const riskAttempts = riskCount * 3;
|
|
for (let attempt = 0; attempt < riskAttempts && riskAdded < riskCount; attempt++) {
|
|
const userId = pickRandom(userIds, rng);
|
|
const resourceId = pickRandom(resourceIds, rng);
|
|
const key = arbiter.keyManager.createCompositeKey(userId, 'risk_score', resourceId);
|
|
if (riskScoreMap.has(key)) continue;
|
|
const value = rng() * 100;
|
|
arbiter.addRelation(getKey(userId), 'risk_score', getKey(resourceId), 1.0, { value });
|
|
riskScoreMap.set(key, value);
|
|
riskAdded++;
|
|
}
|
|
|
|
const riskLimitMap = new Map();
|
|
for (const resourceId of resourceIds) {
|
|
const limit = riskLimitBase + rng() * riskLimitVariance;
|
|
const resourceKey = getKey(resourceId);
|
|
const key = arbiter.keyManager.createCompositeKey(resourceId, 'risk_limit', resourceId);
|
|
arbiter.addRelation(resourceKey, 'risk_limit', resourceKey, 1.0, { value: limit });
|
|
riskLimitMap.set(key, limit);
|
|
}
|
|
|
|
const trustLevelMap = new Map();
|
|
const trustRequirementMap = new Map();
|
|
const trustCount = Math.max(1, Math.round(users * trustPerUser));
|
|
let trustAdded = 0;
|
|
const trustAttempts = trustCount * 3;
|
|
for (let attempt = 0; attempt < trustAttempts && trustAdded < trustCount; attempt++) {
|
|
const userId = pickRandom(userIds, rng);
|
|
const resourceId = pickRandom(resourceIds, rng);
|
|
const value = rng();
|
|
const key = arbiter.keyManager.createCompositeKey(userId, 'trust_level', resourceId);
|
|
if (trustLevelMap.has(key)) continue;
|
|
arbiter.addRelation(getKey(userId), 'trust_level', getKey(resourceId), 1.0, { value });
|
|
trustLevelMap.set(key, value);
|
|
trustAdded++;
|
|
}
|
|
for (const resourceId of resourceIds) {
|
|
const value = trustRequirementBase + rng() * trustRequirementVariance;
|
|
const resourceKey = getKey(resourceId);
|
|
const key = arbiter.keyManager.createCompositeKey(resourceId, 'trust_requirement', resourceId);
|
|
arbiter.addRelation(resourceKey, 'trust_requirement', resourceKey, 1.0, { value });
|
|
trustRequirementMap.set(key, value);
|
|
}
|
|
|
|
const linkEdgesCount = Math.max(1, Math.round(users * linkPerUser));
|
|
for (let i = 0; i < linkEdgesCount; i++) {
|
|
const userId = pickRandom(userIds, rng);
|
|
const resourceId = pickRandom(resourceIds, rng);
|
|
addLinkEdge(userId, resourceId);
|
|
}
|
|
const resourceLinksCount = Math.max(1, Math.round(resources * linkPerResource));
|
|
for (let i = 0; i < resourceLinksCount; i++) {
|
|
const srcResource = pickRandom(resourceIds, rng);
|
|
const dstResource = pickRandom(resourceIds, rng);
|
|
if (srcResource !== dstResource) {
|
|
addLinkEdge(srcResource, dstResource);
|
|
}
|
|
}
|
|
const reverseLinksCount = Math.max(1, Math.round(resources * linkReversePerResource));
|
|
for (let i = 0; i < reverseLinksCount; i++) {
|
|
const srcResource = pickRandom(resourceIds, rng);
|
|
const dstUser = pickRandom(userIds, rng);
|
|
addLinkEdge(srcResource, dstUser);
|
|
}
|
|
|
|
for (const userId of userIds) {
|
|
const node = arbiter.nodes.get(userId);
|
|
if (node) {
|
|
node.embedding = arbiter.jsonhash.generateSketch({ type: 'user', id: userId });
|
|
node.stale = false;
|
|
}
|
|
}
|
|
for (const resourceId of resourceIds) {
|
|
const node = arbiter.nodes.get(resourceId);
|
|
if (node) {
|
|
node.embedding = arbiter.jsonhash.generateSketch({ type: 'resource', id: resourceId });
|
|
node.stale = false;
|
|
}
|
|
}
|
|
if (arbiter.embeddingManager) {
|
|
arbiter.embeddingManager.clearPendingStaleNodes();
|
|
}
|
|
arbiter.embeddingManager = null;
|
|
|
|
const relationPools = {
|
|
direct: [
|
|
{ name: 'viewer', srcType: 'user', dstType: 'resource', srcPool: userIds, dstPool: resourceIds, keySet: viewerSet },
|
|
{ name: 'owner', srcType: 'user', dstType: 'resource', srcPool: userIds, dstPool: resourceIds, keySet: ownerSet },
|
|
{ name: 'blocked', srcType: 'user', dstType: 'resource', srcPool: userIds, dstPool: resourceIds, keySet: blockedSet },
|
|
{ name: 'member', srcType: 'user', dstType: 'group', srcPool: userIds, dstPool: groupIds, keySet: memberKeySet },
|
|
{ name: 'group_access', srcType: 'group', dstType: 'resource', srcPool: groupIds, dstPool: resourceIds, keySet: groupAccessKeySet },
|
|
{ name: 'link', srcType: 'user', dstType: 'resource', srcPool: userIds, dstPool: resourceIds, keySet: linkKeySet },
|
|
{ name: 'link', srcType: 'resource', dstType: 'resource', srcPool: resourceIds, dstPool: resourceIds, keySet: linkKeySet },
|
|
{ name: 'link', srcType: 'resource', dstType: 'user', srcPool: resourceIds, dstPool: userIds, keySet: linkKeySet },
|
|
{ name: 'resource_access', srcType: 'resource', dstType: 'group', srcPool: resourceIds, dstPool: groupIds, keySet: resourceAccessKeySet }
|
|
],
|
|
value: [
|
|
{ name: 'risk_score', srcType: 'user', dstType: 'resource', srcPool: userIds, dstPool: resourceIds, keyMap: riskScoreMap },
|
|
{ name: 'risk_limit', srcType: 'resource', dstType: 'resource', srcPool: resourceIds, dstPool: resourceIds, keyMap: riskLimitMap },
|
|
{ name: 'trust_level', srcType: 'user', dstType: 'resource', srcPool: userIds, dstPool: resourceIds, keyMap: trustLevelMap },
|
|
{ name: 'trust_requirement', srcType: 'resource', dstType: 'resource', srcPool: resourceIds, dstPool: resourceIds, keyMap: trustRequirementMap }
|
|
]
|
|
};
|
|
|
|
return {
|
|
arbiter,
|
|
userIds,
|
|
groupIds,
|
|
resourceIds,
|
|
relationPools,
|
|
groundTruth: {
|
|
viewerSet,
|
|
ownerSet,
|
|
blockedSet,
|
|
memberMap,
|
|
groupAccessMap,
|
|
viewerByResource,
|
|
viewerByUser,
|
|
riskScoreMap,
|
|
riskLimitMap,
|
|
trustLevelMap,
|
|
trustRequirementMap,
|
|
linkAdjacency,
|
|
linkReverseAdjacency,
|
|
resourceAccessMap
|
|
}
|
|
};
|
|
}
|
|
|
|
function runQueries({
|
|
arbiter,
|
|
total,
|
|
valueRatio,
|
|
samplers,
|
|
rng,
|
|
recordFrequency,
|
|
frequencies,
|
|
relationPools,
|
|
validation
|
|
}) {
|
|
const relationManager = arbiter.relationManager;
|
|
const latencies = [];
|
|
let mismatches = 0;
|
|
let falsePositives = 0;
|
|
let falseNegatives = 0;
|
|
for (let i = 0; i < total; i++) {
|
|
const start = process.hrtime.bigint();
|
|
const useValue = relationPools.value.length > 0 && rng() < valueRatio;
|
|
const relationDef = useValue ? pickRandom(relationPools.value, rng) : pickRandom(relationPools.direct, rng);
|
|
const srcSampler = samplers[relationDef.srcType];
|
|
const dstSampler = samplers[relationDef.dstType];
|
|
const srcId = relationDef.srcPool[srcSampler()];
|
|
const dstId = relationDef.dstPool[dstSampler()];
|
|
const relation = relationDef.name;
|
|
const cacheKey = useValue
|
|
? relationManager._makeValueCacheKey(srcId, relation, dstId)
|
|
: relationManager._makeDirectCacheKey(srcId, relation, dstId);
|
|
|
|
if (recordFrequency) {
|
|
const current = frequencies.get(cacheKey) || 0;
|
|
frequencies.set(cacheKey, current + 1);
|
|
}
|
|
|
|
if (useValue) {
|
|
const result = relationManager.getValueRelation(srcId, relation, dstId);
|
|
if (validation) {
|
|
const expected = relationDef.keyMap.has(cacheKey);
|
|
if (expected && !result) {
|
|
mismatches++;
|
|
falseNegatives++;
|
|
} else if (!expected && result) {
|
|
mismatches++;
|
|
falsePositives++;
|
|
}
|
|
}
|
|
} else {
|
|
const result = relationManager.getDirectRelation(srcId, relation, dstId);
|
|
if (validation) {
|
|
const expected = relationDef.keySet.has(cacheKey);
|
|
if (expected && !result) {
|
|
mismatches++;
|
|
falseNegatives++;
|
|
} else if (!expected && result) {
|
|
mismatches++;
|
|
falsePositives++;
|
|
}
|
|
}
|
|
}
|
|
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
|
|
latencies.push(durationMs);
|
|
}
|
|
return { mismatches, falsePositives, falseNegatives, latencies };
|
|
}
|
|
|
|
function runAuthEval({
|
|
arbiter,
|
|
total,
|
|
samplers,
|
|
rng,
|
|
groundTruth,
|
|
userIds,
|
|
resourceIds,
|
|
debugLimit
|
|
}) {
|
|
let tp = 0;
|
|
let tn = 0;
|
|
let fp = 0;
|
|
let fn = 0;
|
|
let predictedTrue = 0;
|
|
let predictedFalse = 0;
|
|
const latencies = [];
|
|
const debugFailures = [];
|
|
const debugFalsePositives = [];
|
|
const perRuleStats = new Map();
|
|
const authRelations = [
|
|
{ name: 'can_view', weight: 0.12 },
|
|
{ name: 'can_edit', weight: 0.06 },
|
|
{ name: 'can_view_ttu', weight: 0.1 },
|
|
{ name: 'can_view_ttu_reverse', weight: 0.08 },
|
|
{ name: 'can_reach', weight: 0.08 },
|
|
{ name: 'can_reach_reverse', weight: 0.08 },
|
|
{ name: 'similar_view', weight: 0.08 },
|
|
{ name: 'similar_view_reverse', weight: 0.08 },
|
|
{ name: 'risk_ok', weight: 0.06 },
|
|
{ name: 'trust_ok_qual', weight: 0.06 },
|
|
{ name: 'can_view_defeasible', weight: 0.08 },
|
|
{ name: 'can_view_mixed', weight: 0.06 },
|
|
{ name: 'viewer', weight: 0.03 },
|
|
{ name: 'owner', weight: 0.03 }
|
|
];
|
|
const weightSum = authRelations.reduce((sum, item) => sum + item.weight, 0);
|
|
const pickAuthRelation = () => {
|
|
let roll = rng() * weightSum;
|
|
for (const relation of authRelations) {
|
|
if (roll <= relation.weight) return relation.name;
|
|
roll -= relation.weight;
|
|
}
|
|
return authRelations[authRelations.length - 1].name;
|
|
};
|
|
const isBlocked = (userId, resourceId) =>
|
|
groundTruth.blockedSet.has(arbiter.keyManager.createCompositeKey(userId, 'blocked', resourceId));
|
|
const hasDirect = (relation, userId, resourceId) =>
|
|
groundTruth[`${relation}Set`].has(arbiter.keyManager.createCompositeKey(userId, relation, resourceId));
|
|
const ttuExpected = (userId, resourceId) => {
|
|
const groups = groundTruth.memberMap.get(userId);
|
|
if (!groups) return false;
|
|
for (const groupId of groups) {
|
|
const resources = groundTruth.groupAccessMap.get(groupId);
|
|
if (resources && resources.has(resourceId)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const canViewExpected = (userId, resourceId) => {
|
|
if (isBlocked(userId, resourceId)) return false;
|
|
if (hasDirect('viewer', userId, resourceId) || hasDirect('owner', userId, resourceId)) {
|
|
return true;
|
|
}
|
|
return ttuExpected(userId, resourceId);
|
|
};
|
|
const canReachCache = new Map();
|
|
const canReachReverseCache = new Map();
|
|
const canReachExpected = (userId, resourceId) => {
|
|
if (!groundTruth.linkAdjacency) return false;
|
|
if (!canReachCache.has(userId)) {
|
|
const reachable = new Set();
|
|
const queue = [{ id: userId, depth: 0 }];
|
|
const visited = new Set([userId]);
|
|
while (queue.length > 0) {
|
|
const { id, depth } = queue.shift();
|
|
if (depth >= 3) continue;
|
|
const next = groundTruth.linkAdjacency.get(id);
|
|
if (!next) continue;
|
|
for (const neighbor of next) {
|
|
if (!visited.has(neighbor)) {
|
|
visited.add(neighbor);
|
|
reachable.add(neighbor);
|
|
queue.push({ id: neighbor, depth: depth + 1 });
|
|
}
|
|
}
|
|
}
|
|
canReachCache.set(userId, reachable);
|
|
}
|
|
return canReachCache.get(userId).has(resourceId);
|
|
};
|
|
const canReachReverseExpected = (userId, resourceId) => {
|
|
if (!groundTruth.linkReverseAdjacency) return false;
|
|
if (!canReachReverseCache.has(userId)) {
|
|
const reachable = new Set();
|
|
const queue = [{ id: userId, depth: 0 }];
|
|
const visited = new Set([userId]);
|
|
while (queue.length > 0) {
|
|
const { id, depth } = queue.shift();
|
|
if (depth >= 3) continue;
|
|
const next = groundTruth.linkReverseAdjacency.get(id);
|
|
if (!next) continue;
|
|
for (const neighbor of next) {
|
|
if (!visited.has(neighbor)) {
|
|
visited.add(neighbor);
|
|
reachable.add(neighbor);
|
|
queue.push({ id: neighbor, depth: depth + 1 });
|
|
}
|
|
}
|
|
}
|
|
canReachReverseCache.set(userId, reachable);
|
|
}
|
|
return canReachReverseCache.get(userId).has(resourceId);
|
|
};
|
|
const similarityThreshold = 0.6;
|
|
const similarViewExpected = (userId, resourceId) => {
|
|
const viewers = groundTruth.viewerByResource.get(resourceId);
|
|
if (!viewers) return false;
|
|
const userKey = arbiter.keyByNodeId.get(userId);
|
|
const userNode = arbiter.nodes.get(userId);
|
|
for (const viewerId of viewers) {
|
|
const viewerKey = arbiter.keyByNodeId.get(viewerId);
|
|
const viewerNode = arbiter.nodes.get(viewerId);
|
|
if (userNode?.embedding && viewerNode?.embedding) {
|
|
if (arbiter.jsonhash.compareSketches(userNode.embedding, viewerNode.embedding) >= similarityThreshold) {
|
|
return true;
|
|
}
|
|
} else if (basicSimilarity(userKey, viewerKey) >= similarityThreshold) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const similarViewReverseExpected = (userId, resourceId) => {
|
|
const resources = groundTruth.viewerByUser.get(userId);
|
|
if (!resources) return false;
|
|
const resourceKey = arbiter.keyByNodeId.get(resourceId);
|
|
const resourceNode = arbiter.nodes.get(resourceId);
|
|
for (const viewedResourceId of resources) {
|
|
const viewedKey = arbiter.keyByNodeId.get(viewedResourceId);
|
|
const viewedNode = arbiter.nodes.get(viewedResourceId);
|
|
if (resourceNode?.embedding && viewedNode?.embedding) {
|
|
if (arbiter.jsonhash.compareSketches(resourceNode.embedding, viewedNode.embedding) >= similarityThreshold) {
|
|
return true;
|
|
}
|
|
} else if (basicSimilarity(resourceKey, viewedKey) >= similarityThreshold) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const riskOkExpected = (userId, resourceId) => {
|
|
const riskRel = arbiter.relationManager.getDirectRelation(userId, 'risk_score', resourceId);
|
|
const limitRel = arbiter.relationManager.getDirectRelation(resourceId, 'risk_limit', resourceId);
|
|
if (!riskRel || !limitRel) {
|
|
return false;
|
|
}
|
|
const riskValue = arbiter.valueManager.getBlurredValue(riskRel);
|
|
const limitValue = arbiter.valueManager.getBlurredValue(limitRel);
|
|
if (!riskValue || !limitValue || !riskValue.interval || !limitValue.interval) {
|
|
return false;
|
|
}
|
|
const possibility = arbiter.valueManager.compareIntervals(
|
|
riskValue.interval,
|
|
limitValue.interval,
|
|
'<='
|
|
);
|
|
return possibility > 0;
|
|
};
|
|
const trustScale = [0, 0.25, 0.5, 0.75, 1];
|
|
const trustOkQualExpected = (userId, resourceId) => {
|
|
const trustLevel = groundTruth.trustLevelMap.get(
|
|
arbiter.keyManager.createCompositeKey(userId, 'trust_level', resourceId)
|
|
);
|
|
const trustRequirement = groundTruth.trustRequirementMap.get(
|
|
arbiter.keyManager.createCompositeKey(resourceId, 'trust_requirement', resourceId)
|
|
);
|
|
if (trustLevel === undefined || trustRequirement === undefined) return false;
|
|
const left = convertToQualitative(trustLevel, trustScale);
|
|
const right = convertToQualitative(trustRequirement, trustScale);
|
|
return left >= right;
|
|
};
|
|
const canViewDefeasibleExpected = (userId, resourceId) => {
|
|
if (!riskOkExpected(userId, resourceId)) return false;
|
|
if (isBlocked(userId, resourceId)) return false;
|
|
return hasDirect('viewer', userId, resourceId) ||
|
|
hasDirect('owner', userId, resourceId) ||
|
|
ttuExpected(userId, resourceId);
|
|
};
|
|
const ttuReverseExpected = (userId, resourceId) => {
|
|
const groups = groundTruth.memberMap.get(userId);
|
|
if (!groups) return false;
|
|
const resourceGroups = groundTruth.resourceAccessMap.get(resourceId);
|
|
if (!resourceGroups) return false;
|
|
for (const groupId of groups) {
|
|
if (resourceGroups.has(groupId)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const canViewMixedExpected = (userId, resourceId) =>
|
|
canViewExpected(userId, resourceId) ||
|
|
canReachReverseExpected(userId, resourceId) ||
|
|
similarViewReverseExpected(userId, resourceId) ||
|
|
trustOkQualExpected(userId, resourceId);
|
|
const pickFromArray = (arr) => arr[Math.floor(rng() * arr.length)];
|
|
const viewerPairs = Array.from(groundTruth.viewerSet, key => {
|
|
const [userId, , resourceId] = JSON.parse(key);
|
|
return { userId, resourceId };
|
|
});
|
|
const ownerPairs = Array.from(groundTruth.ownerSet, key => {
|
|
const [userId, , resourceId] = JSON.parse(key);
|
|
return { userId, resourceId };
|
|
});
|
|
const ttuPairs = [];
|
|
const groupMembers = new Map();
|
|
for (const [userId, groups] of groundTruth.memberMap.entries()) {
|
|
for (const groupId of groups) {
|
|
addToMapSet(groupMembers, groupId, userId);
|
|
const resources = groundTruth.groupAccessMap.get(groupId);
|
|
if (!resources) continue;
|
|
for (const resourceId of resources) {
|
|
ttuPairs.push({ userId, resourceId });
|
|
}
|
|
}
|
|
}
|
|
const ttuReversePairs = [];
|
|
for (const [resourceId, groups] of groundTruth.resourceAccessMap.entries()) {
|
|
for (const groupId of groups) {
|
|
const members = groupMembers.get(groupId);
|
|
if (!members) continue;
|
|
for (const userId of members) {
|
|
ttuReversePairs.push({ userId, resourceId });
|
|
}
|
|
}
|
|
}
|
|
const buildReachPairs = (adjacency, sourceIds) => {
|
|
const pairs = [];
|
|
for (const sourceId of sourceIds) {
|
|
const reachable = new Set();
|
|
const queue = [{ id: sourceId, depth: 0 }];
|
|
const visited = new Set([sourceId]);
|
|
while (queue.length > 0) {
|
|
const { id, depth } = queue.shift();
|
|
if (depth >= 3) continue;
|
|
const next = adjacency.get(id);
|
|
if (!next) continue;
|
|
for (const neighbor of next) {
|
|
if (!visited.has(neighbor)) {
|
|
visited.add(neighbor);
|
|
reachable.add(neighbor);
|
|
queue.push({ id: neighbor, depth: depth + 1 });
|
|
}
|
|
}
|
|
}
|
|
let added = 0;
|
|
for (const targetId of reachable) {
|
|
pairs.push({ userId: sourceId, resourceId: targetId });
|
|
added++;
|
|
if (added >= 50) break;
|
|
}
|
|
}
|
|
return pairs;
|
|
};
|
|
const canReachPairs = buildReachPairs(groundTruth.linkAdjacency, userIds);
|
|
const userIdSet = new Set(userIds);
|
|
const resourceIdSet = new Set(resourceIds);
|
|
const canReachReversePairs = buildReachPairs(groundTruth.linkReverseAdjacency, userIds)
|
|
.filter(pair => resourceIdSet.has(pair.resourceId));
|
|
const similarViewReversePairs = [];
|
|
for (const [userId, resources] of groundTruth.viewerByUser.entries()) {
|
|
for (const resourceId of resources) {
|
|
similarViewReversePairs.push({ userId, resourceId });
|
|
}
|
|
}
|
|
const riskOkPairs = [];
|
|
for (const key of groundTruth.riskScoreMap.keys()) {
|
|
const [userId, , resourceId] = JSON.parse(key);
|
|
if (riskOkExpected(userId, resourceId)) {
|
|
riskOkPairs.push({ userId, resourceId });
|
|
}
|
|
}
|
|
const trustOkPairs = [];
|
|
for (const key of groundTruth.trustLevelMap.keys()) {
|
|
const [userId, , resourceId] = JSON.parse(key);
|
|
if (trustOkQualExpected(userId, resourceId)) {
|
|
trustOkPairs.push({ userId, resourceId });
|
|
}
|
|
}
|
|
const canViewDefeasiblePairs = ttuPairs.filter(pair => canViewDefeasibleExpected(pair.userId, pair.resourceId));
|
|
const canViewPairs = [...viewerPairs, ...ownerPairs, ...ttuPairs];
|
|
const canViewMixedPairs = [
|
|
...canViewPairs,
|
|
...canReachReversePairs,
|
|
...similarViewReversePairs,
|
|
...trustOkPairs
|
|
].filter(pair => canViewMixedExpected(pair.userId, pair.resourceId));
|
|
const expectedForRelation = (relation, userId, resourceId) => {
|
|
if (relation === 'viewer' || relation === 'owner') {
|
|
return hasDirect(relation, userId, resourceId);
|
|
}
|
|
if (relation === 'can_view') {
|
|
return canViewExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'can_edit') {
|
|
return hasDirect('owner', userId, resourceId) && canViewExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'can_view_ttu') {
|
|
return ttuExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'can_view_ttu_reverse') {
|
|
return ttuReverseExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'can_reach') {
|
|
return canReachExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'can_reach_reverse') {
|
|
return canReachReverseExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'similar_view') {
|
|
return similarViewExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'similar_view_reverse') {
|
|
return similarViewReverseExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'risk_ok') {
|
|
return riskOkExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'trust_ok_qual') {
|
|
return trustOkQualExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'can_view_defeasible') {
|
|
return canViewDefeasibleExpected(userId, resourceId);
|
|
}
|
|
if (relation === 'can_view_mixed') {
|
|
return canViewMixedExpected(userId, resourceId);
|
|
}
|
|
return false;
|
|
};
|
|
const positiveSampleRate = 0.45;
|
|
const maxSampleAttempts = 40;
|
|
const pickPairForRelation = (relation) => {
|
|
const positivePools = {
|
|
viewer: viewerPairs,
|
|
owner: ownerPairs,
|
|
can_view: canViewPairs,
|
|
can_edit: canViewPairs,
|
|
can_view_ttu: ttuPairs,
|
|
can_view_ttu_reverse: ttuReversePairs,
|
|
can_reach: canReachPairs,
|
|
can_reach_reverse: canReachReversePairs,
|
|
similar_view: viewerPairs,
|
|
similar_view_reverse: similarViewReversePairs,
|
|
risk_ok: riskOkPairs,
|
|
trust_ok_qual: trustOkPairs,
|
|
can_view_defeasible: canViewDefeasiblePairs,
|
|
can_view_mixed: canViewMixedPairs
|
|
};
|
|
const pool = positivePools[relation] || [];
|
|
const forcePositive = new Set([
|
|
'can_view_ttu_reverse',
|
|
'can_reach_reverse',
|
|
'similar_view_reverse',
|
|
'trust_ok_qual'
|
|
]);
|
|
if (pool.length > 0 && (forcePositive.has(relation) || rng() < positiveSampleRate)) {
|
|
return pickFromArray(pool);
|
|
}
|
|
if (rng() < positiveSampleRate) {
|
|
const attempts = relation.endsWith('_reverse') ? maxSampleAttempts * 2 : maxSampleAttempts;
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
const candidateUser = userIds[samplers.user()];
|
|
const candidateResource = resourceIds[samplers.resource()];
|
|
if (expectedForRelation(relation, candidateUser, candidateResource)) {
|
|
return { userId: candidateUser, resourceId: candidateResource };
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
userId: userIds[samplers.user()],
|
|
resourceId: resourceIds[samplers.resource()]
|
|
};
|
|
};
|
|
for (let i = 0; i < total; i++) {
|
|
const relation = pickAuthRelation();
|
|
const sampled = pickPairForRelation(relation);
|
|
const userId = sampled.userId;
|
|
const resourceId = sampled.resourceId;
|
|
const expected = expectedForRelation(relation, userId, resourceId);
|
|
const start = process.hrtime.bigint();
|
|
const result = arbiter.check(
|
|
arbiter.keyByNodeId.get(userId),
|
|
relation,
|
|
arbiter.keyByNodeId.get(resourceId)
|
|
);
|
|
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
|
|
latencies.push(durationMs);
|
|
const predicted = result && result.possibility > 0;
|
|
if (predicted) {
|
|
predictedTrue++;
|
|
} else {
|
|
predictedFalse++;
|
|
}
|
|
if (expected && predicted) {
|
|
tp++;
|
|
} else if (!expected && !predicted) {
|
|
tn++;
|
|
} else if (!expected && predicted) {
|
|
fp++;
|
|
if (debugFalsePositives.length < debugLimit) {
|
|
let riskDetails = null;
|
|
if (relation === 'risk_ok') {
|
|
const riskRel = arbiter.relationManager.getDirectRelation(userId, 'risk_score', resourceId);
|
|
const limitRel = arbiter.relationManager.getDirectRelation(resourceId, 'risk_limit', resourceId);
|
|
const riskValue = riskRel ? arbiter.valueManager.getBlurredValue(riskRel) : null;
|
|
const limitValue = limitRel ? arbiter.valueManager.getBlurredValue(limitRel) : null;
|
|
const comparison = riskValue && limitValue && riskValue.interval && limitValue.interval
|
|
? arbiter.valueManager.compareIntervals(riskValue.interval, limitValue.interval, '<=')
|
|
: null;
|
|
riskDetails = {
|
|
riskRelationExists: !!riskRel,
|
|
riskValue: riskRel ? riskRel.value : null,
|
|
riskInterval: riskValue ? riskValue.interval : null,
|
|
limitRelationExists: !!limitRel,
|
|
limitValue: limitRel ? limitRel.value : null,
|
|
limitInterval: limitValue ? limitValue.interval : null,
|
|
comparison
|
|
};
|
|
}
|
|
debugFalsePositives.push({
|
|
relation,
|
|
userId,
|
|
resourceId,
|
|
predictedPossibility: result ? result.possibility : 0,
|
|
predictedReason: result ? result.reason : null,
|
|
predictedMeta: result ? result.meta : null,
|
|
riskDetails
|
|
});
|
|
}
|
|
} else if (expected && !predicted) {
|
|
fn++;
|
|
if (debugFailures.length < debugLimit) {
|
|
const viewerKey = arbiter.keyManager.createCompositeKey(userId, 'viewer', resourceId);
|
|
const ownerKey = arbiter.keyManager.createCompositeKey(userId, 'owner', resourceId);
|
|
const blockedKey = arbiter.keyManager.createCompositeKey(userId, 'blocked', resourceId);
|
|
const viewerDirect = groundTruth.viewerSet.has(viewerKey);
|
|
const ownerDirect = groundTruth.ownerSet.has(ownerKey);
|
|
const blocked = groundTruth.blockedSet.has(blockedKey);
|
|
const memberGroups = groundTruth.memberMap.get(userId) || new Set();
|
|
const accessGroups = [];
|
|
for (const groupId of memberGroups) {
|
|
const resources = groundTruth.groupAccessMap.get(groupId);
|
|
if (resources && resources.has(resourceId)) {
|
|
accessGroups.push(groupId);
|
|
}
|
|
}
|
|
const userKey = arbiter.keyByNodeId.get(userId);
|
|
const resourceKey = arbiter.keyByNodeId.get(resourceId);
|
|
const bypassResult = arbiter.authChecker.check(userKey, relation, resourceKey, { bypassPLTC: true });
|
|
const memberRelations = arbiter.relationManager.getRelationsFromSrc(userId, 'member');
|
|
const groupAccessHasResource = accessGroups.length > 0
|
|
? arbiter.relationManager.getRelationsFromSrc(accessGroups[0], 'group_access').some(rel => rel.dst === resourceId)
|
|
: false;
|
|
const chainRule = {
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member', direction: 'out' },
|
|
{ relation: 'group_access', direction: 'out' }
|
|
]
|
|
};
|
|
const chainResult = arbiter.authChecker.ruleEvaluator.evaluateRule(
|
|
userId,
|
|
userKey,
|
|
resourceId,
|
|
resourceKey,
|
|
chainRule,
|
|
new Set(),
|
|
relation,
|
|
{ bypassPLTC: true }
|
|
);
|
|
debugFailures.push({
|
|
relation,
|
|
userId,
|
|
resourceId,
|
|
viewerDirect,
|
|
ownerDirect,
|
|
blocked,
|
|
memberGroups: Array.from(memberGroups),
|
|
accessGroups,
|
|
memberRelationsCount: memberRelations.length,
|
|
groupAccessHasResource,
|
|
chainPossibility: chainResult ? chainResult.possibility : 0,
|
|
predictedPossibility: result ? result.possibility : 0,
|
|
bypassPossibility: bypassResult ? bypassResult.possibility : 0,
|
|
bypassReason: bypassResult ? bypassResult.reason : null,
|
|
bypassMeta: bypassResult ? bypassResult.meta : null
|
|
});
|
|
}
|
|
}
|
|
if (!perRuleStats.has(relation)) {
|
|
perRuleStats.set(relation, {
|
|
tp: 0,
|
|
tn: 0,
|
|
fp: 0,
|
|
fn: 0,
|
|
predictedTrue: 0,
|
|
predictedFalse: 0,
|
|
latencies: []
|
|
});
|
|
}
|
|
const ruleStats = perRuleStats.get(relation);
|
|
ruleStats.latencies.push(durationMs);
|
|
if (predicted) {
|
|
ruleStats.predictedTrue++;
|
|
} else {
|
|
ruleStats.predictedFalse++;
|
|
}
|
|
if (expected && predicted) {
|
|
ruleStats.tp++;
|
|
} else if (!expected && !predicted) {
|
|
ruleStats.tn++;
|
|
} else if (!expected && predicted) {
|
|
ruleStats.fp++;
|
|
} else if (expected && !predicted) {
|
|
ruleStats.fn++;
|
|
}
|
|
}
|
|
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;
|
|
return {
|
|
tp,
|
|
tn,
|
|
fp,
|
|
fn,
|
|
predictedTrue,
|
|
predictedFalse,
|
|
precision,
|
|
recall,
|
|
f1,
|
|
debugFailures,
|
|
debugFalsePositives,
|
|
latencies,
|
|
perRuleStats
|
|
};
|
|
}
|
|
|
|
const args = parseArgs(process.argv);
|
|
const config = {
|
|
users: Number(args.get('users') || 200),
|
|
groups: Number(args.get('groups') || 100),
|
|
resources: Number(args.get('resources') || 300),
|
|
memberPerUser: Number(args.get('member-per-user') || 3),
|
|
accessPerGroup: Number(args.get('access-per-group') || 6),
|
|
viewerPerUser: Number(args.get('viewer-per-user') || 2),
|
|
ownerPerUser: Number(args.get('owner-per-user') || 1),
|
|
blockedPerUser: Number(args.get('blocked-per-user') || 0.2),
|
|
riskPerUser: Number(args.get('risk-per-user') || 1.5),
|
|
linkPerUser: Number(args.get('link-per-user') || 2),
|
|
linkPerResource: Number(args.get('link-per-resource') || 2),
|
|
linkReversePerResource: Number(args.get('link-reverse-per-resource') || 1),
|
|
riskLimitBase: Number(args.get('risk-limit-base') || 50),
|
|
riskLimitVariance: Number(args.get('risk-limit-variance') || 30),
|
|
trustPerUser: Number(args.get('trust-per-user') || 2),
|
|
trustRequirementBase: Number(args.get('trust-requirement-base') || 0.3),
|
|
trustRequirementVariance: Number(args.get('trust-requirement-variance') || 0.5),
|
|
queries: Number(args.get('queries') || 20000),
|
|
warmup: Number(args.get('warmup') || 2000),
|
|
zipf: Number(args.get('zipf') || 1.1),
|
|
valueRatio: Number(args.get('value-ratio') || 0.2),
|
|
seed: args.has('seed') ? Number(args.get('seed')) : null,
|
|
debugFailures: Number(args.get('debug-failures') || 5)
|
|
};
|
|
|
|
const rng = createRng(config.seed);
|
|
const samplers = {
|
|
user: buildZipfSampler(config.users, config.zipf, rng),
|
|
group: buildZipfSampler(config.groups, config.zipf, rng),
|
|
resource: buildZipfSampler(config.resources, config.zipf, rng)
|
|
};
|
|
const { arbiter, relationPools, groundTruth, userIds, resourceIds } = buildGraph({
|
|
users: config.users,
|
|
groups: config.groups,
|
|
resources: config.resources,
|
|
memberPerUser: config.memberPerUser,
|
|
accessPerGroup: config.accessPerGroup,
|
|
viewerPerUser: config.viewerPerUser,
|
|
ownerPerUser: config.ownerPerUser,
|
|
blockedPerUser: config.blockedPerUser,
|
|
riskPerUser: config.riskPerUser,
|
|
linkPerUser: config.linkPerUser,
|
|
linkPerResource: config.linkPerResource,
|
|
linkReversePerResource: config.linkReversePerResource,
|
|
riskLimitBase: config.riskLimitBase,
|
|
riskLimitVariance: config.riskLimitVariance,
|
|
trustPerUser: config.trustPerUser,
|
|
trustRequirementBase: config.trustRequirementBase,
|
|
trustRequirementVariance: config.trustRequirementVariance,
|
|
rng
|
|
});
|
|
|
|
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
|
arbiter.setRelationConfig('owner', { type: 'direct' });
|
|
arbiter.setRelationConfig('member', { type: 'direct' });
|
|
arbiter.setRelationConfig('group_access', { type: 'direct' });
|
|
arbiter.setRelationConfig('blocked', { type: 'direct' });
|
|
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
|
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
|
arbiter.setRelationConfig('link', { type: 'direct' });
|
|
arbiter.setRelationConfig('resource_access', { type: 'direct' });
|
|
arbiter.setRelationConfig('trust_level', { type: 'direct' });
|
|
arbiter.setRelationConfig('trust_requirement', { type: 'direct' });
|
|
arbiter.setRelationConfig('can_view', {
|
|
union: [
|
|
{ type: 'direct', relation: 'viewer' },
|
|
{ type: 'direct', relation: 'owner' },
|
|
{
|
|
type: 'chain',
|
|
steps: [
|
|
{ relation: 'member', direction: 'out' },
|
|
{ relation: 'group_access', direction: 'out' }
|
|
]
|
|
}
|
|
],
|
|
unless: {
|
|
union: [
|
|
{ type: 'direct', relation: 'blocked' }
|
|
]
|
|
}
|
|
});
|
|
arbiter.setRelationConfig('can_edit', {
|
|
intersection: [
|
|
{ type: 'direct', relation: 'owner' },
|
|
{ type: 'computed', relation: 'can_view' }
|
|
]
|
|
});
|
|
arbiter.setRelationConfig('can_view_ttu', {
|
|
type: 'tuple_to_userset',
|
|
tuplesetRelation: 'group_access',
|
|
tuplesetDirection: 'in',
|
|
computedRelation: 'member'
|
|
});
|
|
arbiter.setRelationConfig('can_view_ttu_reverse', {
|
|
type: 'tuple_to_userset',
|
|
tuplesetRelation: 'member',
|
|
reverse: true,
|
|
computedRelation: 'resource_access'
|
|
});
|
|
arbiter.setRelationConfig('can_reach', {
|
|
type: 'multi_hop',
|
|
relation: 'link',
|
|
maxDepth: 4
|
|
});
|
|
arbiter.setRelationConfig('can_reach_reverse', {
|
|
type: 'multi_hop',
|
|
relation: 'link',
|
|
maxDepth: 4,
|
|
reverse: true
|
|
});
|
|
arbiter.setRelationConfig('similar_view', {
|
|
type: 'similar_to',
|
|
relation: 'viewer',
|
|
k: 5,
|
|
similarityThreshold: 0.6,
|
|
fallbackToBasicSimilarity: true
|
|
});
|
|
arbiter.setRelationConfig('similar_view_reverse', {
|
|
type: 'similar_to',
|
|
relation: 'viewer',
|
|
reverse: true,
|
|
k: 5,
|
|
similarityThreshold: 0.6,
|
|
fallbackToBasicSimilarity: true
|
|
});
|
|
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'
|
|
}
|
|
});
|
|
arbiter.setRelationConfig('trust_ok_qual', {
|
|
type: 'relational_comparator',
|
|
qualitative: true,
|
|
scaleName: 'five-point',
|
|
comparator: '>=',
|
|
fallbackBehavior: 'deny',
|
|
left: {
|
|
rule: { type: 'direct', relation: 'trust_level' },
|
|
extractValue: true,
|
|
valueRelation: 'trust_level'
|
|
},
|
|
right: {
|
|
rule: { type: 'direct', relation: 'trust_requirement' },
|
|
extractValue: true,
|
|
valueRelation: 'trust_requirement',
|
|
evaluateFrom: 'object'
|
|
}
|
|
});
|
|
arbiter.setRelationConfig('can_view_defeasible', {
|
|
union: [
|
|
{ type: 'direct', relation: 'viewer' },
|
|
{ type: 'direct', relation: 'owner' },
|
|
{ type: 'computed', relation: 'can_view_ttu' }
|
|
],
|
|
unless: {
|
|
union: [
|
|
{ type: 'direct', relation: 'blocked' }
|
|
]
|
|
},
|
|
requires: {
|
|
union: [
|
|
{ type: 'computed', relation: 'risk_ok' }
|
|
]
|
|
}
|
|
});
|
|
arbiter.setRelationConfig('can_view_mixed', {
|
|
union: [
|
|
{ type: 'computed', relation: 'can_view' },
|
|
{ type: 'computed', relation: 'can_reach_reverse' },
|
|
{ type: 'computed', relation: 'similar_view_reverse' },
|
|
{ type: 'computed', relation: 'trust_ok_qual' }
|
|
]
|
|
});
|
|
|
|
arbiter.graphManager.initializeReachabilityChecker({ enableBackwardIndex: true });
|
|
|
|
|
|
if (config.warmup > 0) {
|
|
runQueries({
|
|
arbiter,
|
|
total: config.warmup,
|
|
valueRatio: config.valueRatio,
|
|
samplers,
|
|
rng,
|
|
recordFrequency: false,
|
|
frequencies: new Map(),
|
|
relationPools,
|
|
validation: false
|
|
});
|
|
}
|
|
|
|
arbiter.relationManager.resetCacheStats();
|
|
const frequencies = new Map();
|
|
const startTime = Date.now();
|
|
const validation = runQueries({
|
|
arbiter,
|
|
total: config.queries,
|
|
valueRatio: config.valueRatio,
|
|
samplers,
|
|
rng,
|
|
recordFrequency: true,
|
|
frequencies,
|
|
relationPools,
|
|
validation: true
|
|
});
|
|
const elapsedMs = Date.now() - startTime;
|
|
const qps = elapsedMs > 0 ? Math.round((config.queries / elapsedMs) * 1000) : 0;
|
|
const lookupLatencies = [...validation.latencies].sort((a, b) => a - b);
|
|
const lookupP95 = percentile(lookupLatencies, 95).toFixed(3);
|
|
const lookupP99 = percentile(lookupLatencies, 99).toFixed(3);
|
|
|
|
const stats = arbiter.relationManager.getCacheStats();
|
|
const estimatedHitRate = estimateHitRate(frequencies, arbiter.relationManager._maxCacheSize);
|
|
|
|
const authStart = Date.now();
|
|
const authEval = runAuthEval({
|
|
arbiter,
|
|
total: config.queries,
|
|
samplers,
|
|
rng,
|
|
groundTruth,
|
|
userIds,
|
|
resourceIds,
|
|
debugLimit: config.debugFailures
|
|
});
|
|
const authElapsed = Date.now() - authStart;
|
|
const authQps = authElapsed > 0 ? Math.round((config.queries / authElapsed) * 1000) : 0;
|
|
const authLatencies = [...authEval.latencies].sort((a, b) => a - b);
|
|
const authP95 = percentile(authLatencies, 95).toFixed(3);
|
|
const authP99 = percentile(authLatencies, 99).toFixed(3);
|
|
const perRuleOutput = [];
|
|
for (const [relation, stats] of authEval.perRuleStats.entries()) {
|
|
const ruleLatencies = [...stats.latencies].sort((a, b) => a - b);
|
|
const ruleP95 = percentile(ruleLatencies, 95);
|
|
const ruleP99 = percentile(ruleLatencies, 99);
|
|
const precision = stats.tp + stats.fp > 0 ? stats.tp / (stats.tp + stats.fp) : 0;
|
|
const recall = stats.tp + stats.fn > 0 ? stats.tp / (stats.tp + stats.fn) : 0;
|
|
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
|
perRuleOutput.push({
|
|
relation,
|
|
tp: stats.tp,
|
|
tn: stats.tn,
|
|
fp: stats.fp,
|
|
fn: stats.fn,
|
|
predictedTrue: stats.predictedTrue,
|
|
predictedFalse: stats.predictedFalse,
|
|
precision: Number(precision.toFixed(3)),
|
|
recall: Number(recall.toFixed(3)),
|
|
f1: Number(f1.toFixed(3)),
|
|
p95: Number(ruleP95.toFixed(3)),
|
|
p99: Number(ruleP99.toFixed(3))
|
|
});
|
|
}
|
|
perRuleOutput.sort((a, b) => a.relation.localeCompare(b.relation));
|
|
|
|
console.log('Zipfian cache evaluation');
|
|
console.log(`users=${config.users} groups=${config.groups} resources=${config.resources} queries=${config.queries}`);
|
|
console.log(`zipf=${config.zipf} value_ratio=${config.valueRatio} warmup=${config.warmup}`);
|
|
console.log(`cache_hits=${stats.cacheHits} cache_misses=${stats.cacheMisses} hit_rate=${stats.hitRate}`);
|
|
console.log(`relation_cache_size=${stats.relationCacheSize} value_cache_size=${stats.valueCacheSize}`);
|
|
console.log(`estimated_topk_hit_rate=${estimatedHitRate.toFixed(3)}`);
|
|
console.log(`elapsed_ms=${elapsedMs} qps=${qps}`);
|
|
console.log(`lookup_p95_ms=${lookupP95} lookup_p99_ms=${lookupP99}`);
|
|
console.log(`validation_mismatches=${validation.mismatches} false_pos=${validation.falsePositives} false_neg=${validation.falseNegatives}`);
|
|
console.log(`auth_elapsed_ms=${authElapsed} auth_qps=${authQps}`);
|
|
console.log(`auth_p95_ms=${authP95} auth_p99_ms=${authP99}`);
|
|
console.log('auth_per_rule');
|
|
for (const entry of perRuleOutput) {
|
|
console.log(JSON.stringify(entry));
|
|
}
|
|
console.log(`auth_predicted_true=${authEval.predictedTrue} auth_predicted_false=${authEval.predictedFalse}`);
|
|
console.log(`auth_tp=${authEval.tp} auth_tn=${authEval.tn} auth_fp=${authEval.fp} auth_fn=${authEval.fn}`);
|
|
console.log(`auth_precision=${authEval.precision.toFixed(3)} auth_recall=${authEval.recall.toFixed(3)} auth_f1=${authEval.f1.toFixed(3)}`);
|
|
if (authEval.debugFailures.length) {
|
|
console.log('auth_failure_samples');
|
|
for (const sample of authEval.debugFailures) {
|
|
console.log(JSON.stringify(sample));
|
|
}
|
|
}
|
|
if (authEval.debugFalsePositives.length) {
|
|
console.log('auth_false_positive_samples');
|
|
for (const sample of authEval.debugFalsePositives) {
|
|
console.log(JSON.stringify(sample));
|
|
}
|
|
}
|