From 30fc7e5017e4e9cef97d5209ed98028c5c15e023 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 31 Jul 2026 18:54:57 -0700 Subject: [PATCH] js-rigor: TTU reverse+tuplesetDirection-in now honors direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reverse branch of TupleToUsersetRule ignored tuplesetDirection: it looked up the tupleset relation as outgoing-from-user even when 'in' was set, while the join honored 'in' by using t.src as the intermediate — the documented shape (intermediates hold the relation TO the user) never matched, and only a degenerate join-on-user shape produced results. The same gap existed in the compiled direct_join optimization. Fixed the fallback tupleset lookup (including the graph-neighbor path and the computed-join tupleEdge direction) and the optimized direct_join to honor tuplesetDirection in reverse mode. Matrix pins reverse-in (both evaluation paths, persistent + partial), multi_hop reverse, chain-in, union-with-chain-child, defeasible split legs, TTU value flow, and challenge-via-binary. --- src/authorization/CompiledEvaluator.js | 4 +- src/authorization/rules/TupleToUsersetRule.js | 20 ++- tests/rigor/rule-kind-partial-parity.test.js | 128 ++++++++++++++++++ 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/authorization/CompiledEvaluator.js b/src/authorization/CompiledEvaluator.js index 42b9e2a..e912b36 100644 --- a/src/authorization/CompiledEvaluator.js +++ b/src/authorization/CompiledEvaluator.js @@ -208,7 +208,9 @@ export class CompiledEvaluator { const tuplesetDirection = optimized.tuplesetDirection || 'out'; let tuples; if (optimized.reverse) { - tuples = this.arbiter.relationManager.getRelationsFromSrc(userId, optimized.tuplesetRelation, options); + tuples = tuplesetDirection === 'in' + ? this.arbiter.relationManager.getRelationsToDst(userId, optimized.tuplesetRelation, options) + : this.arbiter.relationManager.getRelationsFromSrc(userId, optimized.tuplesetRelation, options); } else { tuples = tuplesetDirection === 'in' ? this.arbiter.relationManager.getRelationsToDst(objectId, optimized.tuplesetRelation, options) diff --git a/src/authorization/rules/TupleToUsersetRule.js b/src/authorization/rules/TupleToUsersetRule.js index 0cecf9d..9939941 100644 --- a/src/authorization/rules/TupleToUsersetRule.js +++ b/src/authorization/rules/TupleToUsersetRule.js @@ -83,19 +83,27 @@ export class TupleToUsersetRule extends BaseRule { 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, false); + this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, reverseLookup); const neighbors = useGraphNeighbors - ? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, false) + ? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, reverseLookup) : null; if (neighbors) { tuples = []; for (const neighborId of neighbors) { - const edge = this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, neighborId, options); + 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 = this.arbiter.relationManager.getRelationsFromSrc(userId, rule.tuplesetRelation, options); + tuples = reverseLookup + ? this.arbiter.relationManager.getRelationsToDst(userId, rule.tuplesetRelation, options) + : this.arbiter.relationManager.getRelationsFromSrc(userId, rule.tuplesetRelation, options); } } else { const reverseLookup = tuplesetDirection === 'in'; @@ -189,7 +197,9 @@ export class TupleToUsersetRule extends BaseRule { processedCount++; const tupleEdge = reverse - ? this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, intermediateId, options) + ? (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)); diff --git a/tests/rigor/rule-kind-partial-parity.test.js b/tests/rigor/rule-kind-partial-parity.test.js index e6d08e6..2b5538a 100644 --- a/tests/rigor/rule-kind-partial-parity.test.js +++ b/tests/rigor/rule-kind-partial-parity.test.js @@ -257,6 +257,134 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { assert.equal(expired.possibility, 0, 'challenge expired'); assert.equal(a.check('u:0', 'can_download', 'doc:0').possibility, 0, 'challenge missing context'); } + // ---- TTU reverse + tuplesetDirection in (intermediates point AT the user) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_view', { type: 'tuple_to_userset', tuplesetRelation: 'member_of', computedRelation: 'viewable', reverse: true, tuplesetDirection: 'in' }); + a.setRelationConfig('viewable', { type: 'direct', relation: 'viewable' }); + a.addRelation('g:0', 'member_of', 'u:0', { possibility: 0.8 }); + a.addRelation('doc:0', 'viewable', 'g:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_view', 'doc:0').possibility), 0.7, 'ttu reverse-in persistent optimized'); + assert.equal(round4(a.check('u:0', 'can_view', 'doc:0', { useCompiled: false }).possibility), 0.7, 'ttu reverse-in persistent fallback'); + a.removeRelation('g:0', 'member_of', 'u:0'); + a.removeRelation('doc:0', 'viewable', 'g:0'); + const r = a.check('u:0', 'can_view', 'doc:0', { + partialGraph: { relations: [ + { src: 'g:0', relation: 'member_of', dst: 'u:0', possibility: 0.8 }, + { src: 'doc:0', relation: 'viewable', dst: 'g:0', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'ttu reverse-in partial'); + } + // ---- multi_hop reverse (backward walk: doc ->member_of-> g:1 ->member_of-> u:0) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', reverse: true, maxDepth: 3 }); + a.addRelation('g:1', 'member_of', 'u:0', { possibility: 0.8 }); + a.addRelation('doc:0', 'member_of', 'g:1', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'multi_hop reverse persistent'); + a.removeRelation('g:1', 'member_of', 'u:0'); + a.removeRelation('doc:0', 'member_of', 'g:1'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'g:1', relation: 'member_of', dst: 'u:0', possibility: 0.8 }, + { src: 'doc:0', relation: 'member_of', dst: 'g:1', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'multi_hop reverse partial'); + } + // ---- chain direction in + partial ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'chain', steps: [ + { relation: 'member_of', direction: 'in' }, + { relation: 'reads', direction: 'out' } + ] }); + a.addRelation('g:0', 'member_of', 'u:0', { possibility: 0.8 }); + a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'chain-in persistent'); + a.removeRelation('g:0', 'member_of', 'u:0'); + a.removeRelation('g:0', 'reads', 'doc:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'g:0', relation: 'member_of', dst: 'u:0', possibility: 0.8 }, + { src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'chain-in partial'); + } + // ---- union with a chain child ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { union: { rules: [ + { type: 'direct', relation: 'owner' }, + { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' }] } + ] } }); + a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.6 }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 }); + a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'union chain child persistent (max 0.7)'); + a.removeRelation('u:0', 'owner', 'doc:0'); + a.removeRelation('u:0', 'member_of', 'g:0'); + a.removeRelation('g:0', 'reads', 'doc:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.6 }, + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 }, + { src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'union chain child partial'); + } + // ---- defeasible split legs (when persistent, unless partial) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } }); + a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 }); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [{ src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0.5 }] } + }); + assert.equal(round4(r.possibility), 0.4, 'defeasible when persistent, unless partial'); + a.removeRelation('u:0', 'owner', 'doc:0'); + const r2 = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }] } + }); + assert.equal(round4(r2.possibility), 0.8, 'defeasible when partial only'); + } + // ---- TTU value flow through the tupleset edge (collectValues) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }); + a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' }); + a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.8, value: 5 }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 }); + const p = a.check('u:0', 'can_read', 'doc:0', { collectValues: true }); + assert.equal(p.possibility, 0.7, 'ttu value persistent decision'); + assert.ok(Array.isArray(p.collectedValues) && p.collectedValues.some(v => v === 'g:0' || v?.entityKey === 'g:0' || v?.value === 5), + `ttu value persistent collects intermediate: ${JSON.stringify(p.collectedValues)}`); + a.removeRelation('doc:0', 'owner', 'g:0'); + a.removeRelation('u:0', 'member_of', 'g:0'); + const r = a.check('u:0', 'can_read', 'doc:0', { + collectValues: true, + partialGraph: { relations: [ + { src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.8, value: 5 }, + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 } + ] } + }); + assert.equal(r.possibility, 0.7, 'ttu value partial decision'); + } + // ---- challenge via binary mode ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_download', { type: 'challenge', challenge: 'captcha', subject: 'user', withinMinutes: 5 }); + const now = Date.now(); + const r = a.check('u:0', 'can_download', 'doc:0', { + binary: true, minAllowPossibility: 0.5, + partialGraph: { challenges: [{ name: 'captcha', subject: 'u:0', issuedAt: now - 60000, expiresAt: now + 60000 }] } + }); + assert.equal(r.possibility, 1, 'challenge binary satisfied'); + assert.equal(r.allow, true, 'challenge binary allow'); + } // ---- binary mode agrees with normal at the same threshold ---- { const a = mkArbiter();