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, // 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) { // reverse honors tuplesetDirection like the forward path: 'in' means // intermediates hold the tupleset relation TO the user (edge.src is the // intermediate), 'out' means the user holds it to the intermediate. const reverseLookup = tuplesetDirection === 'in'; const useGraphNeighbors = useRelationGraph && this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, reverseLookup); const neighbors = useGraphNeighbors ? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, reverseLookup) : null; if (neighbors) { tuples = []; for (const neighborId of neighbors) { const edge = reverseLookup ? this.arbiter.relationManager.getDirectRelation(neighborId, rule.tuplesetRelation, userId, options) : this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, neighborId, options); if (edge) tuples.push(edge); } } else { tuples = reverseLookup ? this.arbiter.relationManager.getRelationsToDst(userId, rule.tuplesetRelation, options) : 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; // Collected values carry the tupleset edge's value when it has one (the // intermediate key alone is not enough for downstream value consumers // like relational_comparator operands); otherwise the intermediate key // is collected for path-based value lookups. const buildCollectedValue = (tupleEdge, tupleSrcKey, intermediateKey) => { if (tupleEdge && typeof tupleEdge.value === 'number') { return this._createCollectedValue( tupleEdge.value, tupleEdge.possibility, [tupleSrcKey, intermediateKey], { entityKey: tupleSrcKey, relation: rule.tuplesetRelation, step: 0 }, { timestamp: tupleEdge.changed_last_at || tupleEdge.updated_last_at || Date.now(), reliability: tupleEdge.reliability || 1.0, source: tupleEdge.source || 'persistent' } ); } return intermediateKey; }; // 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 ? (tuplesetDirection === 'in' ? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, userId, options) : 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); // A 0-strength "match" (e.g. the computed leg missing with // minPossibility 0) is not a valid path: it would report // tuple_to_userset_found and leak the tupleset value into the // collected values on a denied decision. if (combinedPossibility > 0 && combinedPossibility >= minPossibility) { const path = { possibility: combinedPossibility, reliability: combinedReliability, intermediateKey, tuplesetPossibility: resolvePossibility(tupleEdge.possibility), computedPossibility: res.possibility, ...(!useLightweightPaths && includeMeta && { meta: { intermediateKey, pathType: 'direct_tuple', tuplesetRelation: { relation: rule.tuplesetRelation, possibility: resolvePossibility(tupleEdge.possibility), reliability: resolveReliability(tupleEdge.reliability) }, computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta } } }), ...(!useLightweightPaths && collectValues && { collectedValue: buildCollectedValue(tupleEdge, this.arbiter.resolveKey(tupleEdge.src, options), intermediateKey) }) }; allValidPaths.push(path); if (!bestPath || combinedPossibility > bestPath.possibility) { 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); // 0-strength tuples (missing computed leg, or 0-possibility edges) // must not count as found paths (see computed join mode). if (combinedPossibility > 0 && 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: buildCollectedValue(t, entry.srcKey, 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; } } if (fusedReliability === 1.0 && reliabilities.length > 0) { // Fallback: the fusion aggregator is max, so the winner is the // max-possibility path — its reliability, not the max reliability // (which would pair a possibility from one intermediate with a // reliability from another). const maxIndex = possibilities.indexOf(Math.max(...possibilities)); fusedReliability = maxIndex >= 0 ? (reliabilities[maxIndex] ?? 1.0) : 1.0; } 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 }; } }