Skip to content

fix(query): decide the alias self edge by comparison - #3386

Open
jzelinskie wants to merge 2 commits into
authzed:mainfrom
jzelinskie:qp-self-edge
Open

jzelinskie wants to merge 2 commits into
authzed:mainfrom
jzelinskie:qp-self-edge

Conversation

@jzelinskie

Copy link
Copy Markdown
Member

Description

The query planner decided the reflexive identity subject (group:a#member being one of the subjects of group:a#member) with a datastore query. That query was expensive, and it gave wrong answers. This PR swaps it for the comparison the classic dispatcher makes, and fixes the Check path that the swap exposed.

perf(query): decide the alias self edge by comparison, not a query

The existing check (SubjectExistsAsRelationship) ran a LIMIT 1 query per object per alias level:

  • Expensive: it was 163 of the 175 queries in CheckWideGroups LookupSubjects, all distinct, and every resulting self edge was then thrown away.
  • Wrong: it asked whether any relationship pointed at the object, which has nothing to do with whether identity holds. Adding an unrelated relationship could flip the answer.

Like classic (internal/graph/lookupsubjects.go), the alias now compares the requested subject relation against its own. The requested target lives in Context.TargetSubjectType, kept separate from filterSubjectType, which arrows and recursion leave empty. It crosses dispatch hops as PlanContext.target_subject_relation (field 8). This commit also adds a CountingReader for measuring datastore round-trips. CheckWideGroups LookupSubjects goes from 175 queries to 12, and no results change.

fix(query): keep the self edge when Check resolves through recursion

A recursive Check resolves by running the IterSubjects machinery, and every alias in it skipped the self edge because the operation was a Check. For example, Check(folder:strategy#view <- folder:company#view) returned MEMBER in classic but no path in the planner. recursiveCheckIterSubjects now sets the target to the subject being checked for the duration of its traversal, and restores the previous value afterwards.

Notes:

  • Saving and restoring the target on Context is only sound because a single goroutine drives each Context. A TODO in context.go describes passing it as a parameter instead.
  • The target isn't in any cache key, and doesn't need to be: only Check is cached, and no IterSubjectsImpl issues a Check. Planner LookupSubjects caching, if added, must include the target.

Testing

  • New tests, with expected values taken from the classic dispatcher: TestAliasSelfEdge, TestCheckSelfEdgeThroughRecursion, TestCountingReader.
  • queryconsistency passes. It covers classic vs. planner Check and LookupResources.
  • Commands:
    go test ./pkg/query/... ./internal/dispatch/...
    go test -tags integration ./internal/services/integrationtesting/queryconsistency/...

@jzelinskie
jzelinskie requested a review from a team as a code owner September 29, 2026 17:59
@github-actions github-actions Bot added area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools) area/dispatch Affects dispatching of requests labels Sep 29, 2026
@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The reflexive identity subject (group:a#member is one of the subjects of
group:a#member) was gated on an existence probe: a LIMIT 1 query per object
per alias level, filtered on subject type, ID and relation with no resource
constraint at all. On CheckWideGroups' LookupSubjects that was 163 of 175
queries, all distinct so no cache would help, all returning true, and every
resulting self edge then discarded by the target type filter.

The probe was also wrong. Whether identity holds depends on what the object
is, not on whether some unrelated relationship happens to point at it:

  group:eng#member@user:alice only
    planner LS(group:eng#member, target group#member) = []
    classic LS(group:eng#member, target group#member) = [group:eng#member]

  adding group:parent#member@group:eng#member, which says nothing about
  eng's own members, flips the planner to [group:eng#member].

The classic dispatcher decides this with a comparison and never queries for
it (internal/graph/lookupsubjects.go, req.SubjectRelation against
req.ResourceRelation). Do the same: Context.TargetSubjectType records what
the request asked for, kept separate from the per-call filterSubjectType,
which arrows and recursion legitimately leave empty because they must walk
intermediate-typed results to keep traversing. The decision is per node, not
per traversal — in `active = member - banned` with a target of group#member
it holds for `member` and not for `banned`, and that asymmetry is what makes
the exclusion come out right.

The target has to cross the dispatch boundary too: the receiver builds a
fresh Context, so its aliases saw no target and silently omitted identities
the sender included. PlanContext.top_level_operation already exists for this
exact reason, so target_subject_relation joins it as field 8.

Expected values in the new tests were captured from the classic dispatcher.
CheckWideGroups LookupSubjects drops from 175 datastore queries to 12 with
no change to any result.

This alone leaves queryconsistency failing, because it lets LookupSubjects
return an identity subject that the planner's Check does not agree with; the
Check side is fixed in the commit that follows.
Check decides the reflexive identity subject locally in resolveCheckPath, by
comparing the resource to the subject, and that works when the subject's
object is the resource being checked. It is never reached when the subject is
reached *through* the graph: a recursive permission resolves via
recursiveCheckIterSubjects, which answers by running the IterSubjects
machinery, and every alias in that traversal took shouldIncludeSelfEdge's
early return because the operation that started it was a Check.

The result was a wrong answer, verified against the classic dispatcher:

  folder { relation parent: folder; relation viewer: user | folder#view
           permission view = viewer + owner + parent->view }
  folder:strategy#parent@folder:company

                                          classic   planner
  Check(strategy#view <- company#view)     MEMBER    no path
  Check(strategy#view <- company#viewer)   MEMBER    no path
  Check(strategy#view <- strategy#view)    MEMBER    MEMBER
  Check(strategy#view <- strategy#viewer)  MEMBER    MEMBER

Note the second row: identity holds at `viewer`, not only at the recursion's
own relation, so anything keyed to the recursion relation alone would have
been incomplete.

Give the traversal the subject it is checking. recursiveCheckIterSubjects
sets Context.TargetSubjectType for the duration of its BFS and restores it
afterwards, and the alias gate accepts a Check that has a target. Nothing
else changes: the result filter already matches on object equality ignoring
the relation, so both self edges above satisfy it without touching
filterSubjectType — which must stay relation-free, since it is also the
filter arrows and recursion leave empty in order to keep traversing.

This supersedes the TODO added in the previous commit, and unblocks the
LookupSubjects identity fix: queryconsistency now passes, having failed on
the pair where LookupSubjects and Check disagreed.

Two things found while measuring, both pre-existing and left alone:
recursiveCheckIterResources answers all four cases correctly and would have
been a one-line "fix", but it changes the shape of every recursive Check
(ShareWith goes from 12 datastore queries to 23) and leaves the IterSubjects
direction wrong, so it belongs to the advisor as a cost decision rather than
here. recursiveCheckDeepening does not terminate usefully on this schema —
buildTreeAtDepth climbs to maxDepth building exponentially larger trees — so
selecting that strategy is a hazard.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/dispatch Affects dispatching of requests area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools) Skip-Changelog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant