Files
core/src/authorization/rules/TupleToUsersetRule.js
T

543 lines
22 KiB
JavaScript
Raw Normal View History

import { BaseRule } from './BaseRule.js';
import { Arbiter } from '../../core/Arbiter.js';
import { OWAFusion } from '../../utils/OWAFusion.js';
/**
* TupleToUsersetRule - Evaluates access through intermediate entities (group membership pattern)
*
* This rule implements the tuple-to-userset pattern where access is granted if:
* 1. Object has a tupleset relation to an intermediate entity (e.g., doc -> group)
* 2. User has a computed relation to that same intermediate entity (e.g., user -> group)
*
* Supports sophisticated batch processing and OWA fusion for combining multiple paths.
*
* Configuration:
* {
* type: 'tuple_to_userset',
* tuplesetRelation: string, // Relation from object to intermediate (e.g., 'owner')
* computedRelation: string, // Relation from user to intermediate (e.g., 'member_of')
* reverse: boolean, // Check in reverse direction (default: false)
* minPossibility: number, // Minimum possibility for a path to be considered (0-1, default: 0)
* owaWeights: Array<number>, // OWA weights for fusion (default: 'max' like behavior [1,0,...])
* earlyExitThreshold: number, // Stop when path exceeds this threshold (default: 0.95)
* maxIntermediates: number // Maximum intermediates to check (default: 20)
* }
*/
export class TupleToUsersetRule extends BaseRule {
constructor(arbiter) {
super(arbiter);
// Performance tracking
this.performanceStats = {
totalChecks: 0,
earlyExits: 0,
maxIntermediatesHit: 0
};
}
/**
* Evaluate tuple-to-userset relationship with aggressive early exit optimization
* @protected
*/
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
this.performanceStats.totalChecks++;
const { fastPath, includeMeta = true } = options;
const collectValues = options.collectValues !== undefined ? options.collectValues : true;
const minPossibility = rule.minPossibility !== undefined ? rule.minPossibility : (options.minPossibility !== undefined ? options.minPossibility : 0);
const earlyExitThreshold = rule.earlyExitThreshold !== undefined ? rule.earlyExitThreshold : 0.95;
const maxIntermediates = rule.maxIntermediates !== undefined ? rule.maxIntermediates : 20;
const resolvePossibility = (value) => value !== undefined ? value : 1.0;
const resolveReliability = (value) => value !== undefined ? value : 1.0;
const reverse = rule.reverse || false;
if (!rule._computedRelationId) {
rule._computedRelationId = this.arbiter.keyManager._getRelationId(rule.computedRelation);
}
if (!rule.computedRelationConfig) {
rule.computedRelationConfig = this.arbiter.relationConfigs.get(rule.computedRelation);
}
if (!rule.tuplesetRelationConfig) {
rule.tuplesetRelationConfig = this.arbiter.relationConfigs.get(rule.tuplesetRelation);
}
const ruleMetaBase = includeMeta ? {
ruleType: 'TupleToUsersetRule',
userKey,
objectKey,
tuplesetRelation: rule.tuplesetRelation,
computedRelation: rule.computedRelation,
reverse,
minPossibilityUsed: minPossibility,
earlyExitThreshold,
maxIntermediates,
evaluationStarted: Date.now()
} : null;
let evaluationMeta = options.trackEvaluation && includeMeta ? { ...ruleMetaBase } : null;
// Get tuples (intermediate entities) with early circuit breaker
// tuplesetDirection: 'out' = object has relation TO intermediates (document → owner → group)
// tuplesetDirection: 'in' = intermediates have relation TO object (group → belongs_to → org)
const tuplesetDirection = rule.tuplesetDirection || 'out'; // Default to 'out' for backward compatibility
let tuples;
const useRelationGraph = !options?.partialGraphContext;
if (reverse) {
const useGraphNeighbors = useRelationGraph &&
this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, false);
const neighbors = useGraphNeighbors
? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, false)
: null;
if (neighbors) {
tuples = [];
for (const neighborId of neighbors) {
const edge = this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, neighborId, options);
if (edge) tuples.push(edge);
}
} else {
tuples = this.arbiter.relationManager.getRelationsFromSrc(userId, rule.tuplesetRelation, options);
}
} else {
const reverseLookup = tuplesetDirection === 'in';
const useGraphNeighbors = useRelationGraph &&
this.arbiter.relationManager.shouldUseRelationGraphTraversal(objectId, rule.tuplesetRelation, reverseLookup);
const neighbors = useGraphNeighbors
? this.arbiter.relationManager.getRelationGraphNeighbors(objectId, rule.tuplesetRelation, reverseLookup)
: null;
if (neighbors) {
tuples = [];
for (const neighborId of neighbors) {
const srcId = reverseLookup ? neighborId : objectId;
const dstId = reverseLookup ? objectId : neighborId;
const edge = this.arbiter.relationManager.getDirectRelation(srcId, rule.tuplesetRelation, dstId, options);
if (edge) tuples.push(edge);
}
} else if (reverseLookup) {
tuples = this.arbiter.relationManager.getRelationsToDst(objectId, rule.tuplesetRelation, options);
} else {
tuples = this.arbiter.relationManager.getRelationsFromSrc(objectId, rule.tuplesetRelation, options);
}
}
// Circuit breaker: if too many intermediates, limit and warn
if (tuples.length > maxIntermediates * 3) {
// Sort by possibility and take top N
tuples.sort((a, b) => resolvePossibility(b.possibility) - resolvePossibility(a.possibility));
tuples = tuples.slice(0, maxIntermediates);
if (evaluationMeta) evaluationMeta.circuitBreakerTriggered = true;
}
const checkingId = reverse ? this.arbiter.resolveNodeId(objectKey, options) : userId;
const useDirectJoin = rule.computedRelation && rule.computedRelationConfig?.type === 'direct';
const computedEdges = useDirectJoin
? this.arbiter.relationManager.getRelationsFromSrc(checkingId, rule.computedRelation, options)
: null;
const joinMode = useDirectJoin && computedEdges && computedEdges.length < tuples.length
? 'computed'
: 'tuples';
const tuplesWithKeys = [];
if (includeMeta || joinMode === 'tuples') {
for (const t of tuples) {
const srcKey = this.arbiter.resolveKey(t.src, options);
const dstKey = this.arbiter.resolveKey(t.dst, options);
const intermediateId = tuplesetDirection === 'in' ? t.src : t.dst;
const intermediateKey = tuplesetDirection === 'in' ? srcKey : dstKey;
tuplesWithKeys.push({ tuple: t, srcKey, dstKey, intermediateId, intermediateKey });
}
}
if (evaluationMeta) {
evaluationMeta.directTuplesFound = tuplesWithKeys.length;
evaluationMeta.directTuples = tuplesWithKeys.map(({ tuple, srcKey, dstKey }) => ({
from: srcKey,
to: dstKey,
relation: tuple.rel,
possibility: tuple.possibility,
reliability: tuple.reliability,
source: tuple.source || 'persistent'
}));
}
const useLightweightPaths = !includeMeta && !collectValues;
let bestPath = null;
let allValidPaths = [];
let reasons = [];
let intermediateEvaluationDetails = includeMeta ? [] : null;
let processedCount = 0;
// Process direct tuples with early exit optimization
const computedRelationCache = new Map();
const computedByIntermediate = joinMode === 'tuples' && computedEdges
? new Map(computedEdges.map(edge => [edge.dst, edge]))
: null;
if (joinMode === 'computed' && computedEdges) {
for (const edge of computedEdges) {
if (processedCount >= maxIntermediates) {
if (evaluationMeta) evaluationMeta.maxIntermediatesReached = true;
this.performanceStats.maxIntermediatesHit++;
break;
}
const intermediateId = edge.dst;
const intermediateKey = this.arbiter.resolveKey(intermediateId, options);
if (!intermediateKey) continue;
processedCount++;
const tupleEdge = reverse
? this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, intermediateId, options)
: (tuplesetDirection === 'in'
? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, objectId, options)
: this.arbiter.relationManager.getDirectRelation(objectId, rule.tuplesetRelation, intermediateId, options));
if (!tupleEdge) continue;
let res;
if (visited && visited.size) {
const visitKey = `${checkingId}|${rule._computedRelationId}|${intermediateId}`;
if (visited.has(visitKey)) {
res = { possibility: 0, reliability: 1.0, reason: 'cycle' };
}
}
if (!res) {
res = {
possibility: edge.possibility,
reliability: edge.reliability !== undefined ? edge.reliability : 1.0,
reason: 'direct_match'
};
}
if (includeMeta && intermediateEvaluationDetails) {
const pathDetail = {
intermediateKey,
type: 'direct_tuple',
tuplesetRelationPossibility: resolvePossibility(tupleEdge.possibility),
tuplesetRelationReliability: resolveReliability(tupleEdge.reliability),
computedRelationResult: {
possibility: res.possibility,
reliability: res.reliability,
reason: res.reason
},
metaFromCheck: res.meta
};
if (evaluationMeta) intermediateEvaluationDetails.push(pathDetail);
}
if (res.reason === 'cycle') reasons.push('cycle');
const combinedPossibility = Math.min(resolvePossibility(tupleEdge.possibility), res.possibility);
const combinedReliability = resolveReliability(tupleEdge.reliability) * (res.reliability !== undefined ? res.reliability : 1.0);
if (combinedPossibility >= minPossibility) {
const path = {
intermediateKey,
tuplesetPossibility: resolvePossibility(tupleEdge.possibility),
computedPossibility: res.possibility,
combinedPossibility,
combinedReliability
};
allValidPaths.push(path);
if (!bestPath || combinedPossibility > bestPath.combinedPossibility) {
bestPath = path;
}
}
}
}
if (joinMode === 'tuples') for (const entry of tuplesWithKeys) {
const t = entry.tuple;
if (processedCount >= maxIntermediates) {
if (evaluationMeta) evaluationMeta.maxIntermediatesReached = true;
this.performanceStats.maxIntermediatesHit++;
break;
}
// For 'out' direction: intermediate is destination (document → owner → group)
// For 'in' direction: intermediate is source (group → belongs_to → org)
const intermediateKey = entry.intermediateKey;
if (!intermediateKey) continue;
processedCount++;
// In reverse mode: check if the "object" (3rd param) has computed relation to intermediate
// In normal mode: check if the "user" (1st param) has computed relation to intermediate
const checkingEntityKey = reverse ? objectKey : userKey;
let res = computedRelationCache.get(entry.intermediateId);
if (!res) {
if (useDirectJoin) {
if (visited && visited.size) {
const visitKey = `${checkingId}|${rule._computedRelationId}|${entry.intermediateId}`;
if (visited.has(visitKey)) {
res = {
possibility: 0,
reliability: 1.0,
reason: 'cycle'
};
}
}
if (!res) {
const directRel = computedByIntermediate
? computedByIntermediate.get(entry.intermediateId)
: this.arbiter.relationManager.getDirectRelation(checkingId, rule.computedRelation, entry.intermediateId, options);
if (directRel && (minPossibility === null || directRel.possibility >= minPossibility)) {
res = {
possibility: directRel.possibility,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
reason: 'direct_match'
};
} else {
res = {
possibility: 0,
reliability: 1.0,
reason: 'no_direct_match'
};
}
}
} else {
res = this.arbiter.authChecker.check(checkingEntityKey, rule.computedRelation, intermediateKey, {
...options,
minPossibility,
fastPath: true, // Enable fast path for intermediate checks
_visited: visited,
_currentRelation: rule.computedRelation
});
}
computedRelationCache.set(entry.intermediateId, res);
}
if (includeMeta && intermediateEvaluationDetails) {
const pathDetail = {
intermediateKey,
type: 'direct_tuple',
tuplesetRelationPossibility: resolvePossibility(t.possibility),
tuplesetRelationReliability: resolveReliability(t.reliability),
computedRelationResult: {
possibility: res.possibility,
reliability: res.reliability,
reason: res.reason
},
metaFromCheck: res.meta
};
if (evaluationMeta) intermediateEvaluationDetails.push(pathDetail);
}
if (res.reason === 'cycle') reasons.push('cycle');
const combinedPossibility = Math.min(resolvePossibility(t.possibility), res.possibility);
const combinedReliability = resolveReliability(t.reliability) * (res.reliability !== undefined ? res.reliability : 1.0);
if (combinedPossibility >= minPossibility) {
const pathData = {
possibility: combinedPossibility,
reliability: combinedReliability,
...(!useLightweightPaths && includeMeta && {
meta: {
intermediateKey,
pathType: 'direct',
tuplesetRelation: { relation: rule.tuplesetRelation, possibility: resolvePossibility(t.possibility), reliability: resolveReliability(t.reliability) },
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
}
}),
...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey })
};
allValidPaths.push(pathData);
// Update best path
if (!bestPath || combinedPossibility > bestPath.possibility) {
bestPath = pathData;
}
// AGGRESSIVE EARLY EXIT: Stop if we found a very good path
if (combinedPossibility >= earlyExitThreshold) {
if (evaluationMeta) {
evaluationMeta.earlyExitTriggered = true;
evaluationMeta.earlyExitReason = 'direct_path_threshold_exceeded';
evaluationMeta.earlyExitPossibility = combinedPossibility;
evaluationMeta.intermediatesProcessed = processedCount;
}
this.performanceStats.earlyExits++;
// Return immediately with the excellent path
return this._buildFinalResult(
[pathData],
reasons,
ruleMetaBase,
evaluationMeta,
'early_exit_direct_path',
includeMeta,
collectValues,
options.trackEvaluation
);
}
}
}
if (evaluationMeta) {
evaluationMeta.evaluationCompleted = Date.now();
evaluationMeta.evaluationDuration = evaluationMeta.evaluationCompleted - (evaluationMeta.evaluationStarted || evaluationMeta.evaluationCompleted);
evaluationMeta.intermediateEvaluationDetails = intermediateEvaluationDetails;
evaluationMeta.totalValidPathsFound = allValidPaths.length;
evaluationMeta.intermediatesProcessed = processedCount;
}
return this._buildFinalResult(allValidPaths, reasons, ruleMetaBase, evaluationMeta, 'complete_evaluation', includeMeta, collectValues, options.trackEvaluation);
}
/**
* Build the final result from valid paths
* @private
*/
_buildFinalResult(validPathData, reasons, ruleMetaBase, evaluationMeta, evaluationType, includeMeta = true, collectValues = true, trackEvaluation = false) {
if (!validPathData.length) {
const reason = reasons.includes('cycle') ? 'cycle' : 'no_valid_intermediate_paths';
if (evaluationMeta) {
evaluationMeta.outcome = reason;
evaluationMeta.finalPossibility = 0;
evaluationMeta.finalReliability = 1.0;
evaluationMeta.evaluationType = evaluationType;
}
return {
possibility: 0,
reliability: 1.0,
...(includeMeta && { meta: { ...ruleMetaBase, outcomeReason: reason, evaluation: evaluationMeta } }),
...(collectValues && { collectedValues: [] }),
reason
};
}
// For single path (common with early exit), skip OWA fusion overhead
if (validPathData.length === 1) {
const singlePath = validPathData[0];
if (evaluationMeta) {
evaluationMeta.outcome = 'single_path_found';
evaluationMeta.finalPossibility = singlePath.possibility;
evaluationMeta.finalReliability = singlePath.reliability;
evaluationMeta.evaluationType = evaluationType;
evaluationMeta.fusionSkipped = 'single_path_optimization';
}
return {
possibility: singlePath.possibility,
reliability: singlePath.reliability,
...(includeMeta && {
meta: {
...ruleMetaBase,
outcomeReason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_evaluated',
pathMeta: singlePath.meta,
evaluation: evaluationMeta
}
}),
...(collectValues && { collectedValues: [singlePath.collectedValue] }),
reason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_found'
};
}
if (!includeMeta && !collectValues) {
let maxPossibility = 0;
let maxReliability = 1.0;
for (const path of validPathData) {
if (path.possibility > maxPossibility) {
maxPossibility = path.possibility;
maxReliability = path.reliability;
}
}
return {
possibility: maxPossibility,
reliability: maxReliability,
reason: reasons.includes('cycle') ? 'cycle' : (maxPossibility > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')
};
}
// Multiple paths - use OWA fusion
const possibilities = validPathData.map(p => p.possibility);
const reliabilities = validPathData.map(p => p.reliability);
const metaObjects = includeMeta ? validPathData.map(p => p.meta) : [];
const collectedValues = collectValues ? validPathData.map(p => p.collectedValue) : [];
const owaWeights = [1, ...Array(possibilities.length - 1).fill(0)]; // Default to MAX for performance
const fusionResult = OWAFusion.fuseWithMeta(possibilities, metaObjects, owaWeights, 'max', true, trackEvaluation ? { includeTrace: true } : null);
let fusedReliability = 1.0;
if (fusionResult.meta && fusionResult.meta.intermediateKey) {
const winningPath = validPathData.find(p => p.meta.intermediateKey === fusionResult.meta.intermediateKey && p.meta.pathType === fusionResult.meta.pathType);
if (winningPath) {
fusedReliability = winningPath.reliability;
} else if (reliabilities.length > 0) {
fusedReliability = Math.max(...reliabilities); // Use max reliability for performance
}
} else if (reliabilities.length > 0) {
fusedReliability = Math.max(...reliabilities);
}
if (evaluationMeta) {
evaluationMeta.outcome = 'paths_evaluated_and_fused';
evaluationMeta.fusion = {
method: 'owa',
weightsUsed: owaWeights,
fusedPossibility: fusionResult.value,
fusedReliability: fusedReliability,
winningPathMeta: fusionResult.meta,
...(trackEvaluation && fusionResult.trace ? {
owa: {
level: null,
aggregator: 'max',
weights: fusionResult.trace.weights,
sortedValues: fusionResult.trace.sortedValues,
contributions: fusionResult.trace.contributions,
selectedIndex: fusionResult.trace.selectedIndex
}
} : {})
};
evaluationMeta.finalPossibility = fusionResult.value;
evaluationMeta.finalReliability = fusedReliability;
evaluationMeta.evaluationType = evaluationType;
}
const finalMeta = includeMeta ? {
...ruleMetaBase,
outcomeReason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_evaluated',
fusionMethod: 'owa',
owaWeightsUsed: owaWeights,
contributingPathMeta: fusionResult.meta,
evaluation: evaluationMeta
} : null;
return {
possibility: fusionResult.value,
reliability: fusedReliability,
...(includeMeta && { meta: finalMeta }),
...(collectValues && { collectedValues: fusionResult.value > 0 ? collectedValues : [] }),
reason: reasons.includes('cycle') ? 'cycle' : (fusionResult.value > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')
};
}
/**
* Get performance statistics
* @returns {Object} Performance statistics
*/
getPerformanceStats() {
return { ...this.performanceStats };
}
/**
* Reset performance statistics
*/
resetPerformanceStats() {
this.performanceStats = {
totalChecks: 0,
earlyExits: 0,
maxIntermediatesHit: 0
};
}
}