Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Updated CI so that Postgres tests run against v18 which is GA and not against v13 which is EOL (https://github.com/authzed/spicedb/pull/2926)
- Added tracing to request validation (https://github.com/authzed/spicedb/pull/2950)
- Query Planner optimization: in Check requests, prune branches that cannot lead to the subject type specified.

### Fixed
- Regression introduced in 1.49.2: missing spans in ReadSchema calls (https://github.com/authzed/spicedb/pull/2947)
Expand Down
7 changes: 7 additions & 0 deletions internal/services/v1/permissions_queryplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@
return nil, ps.rewriteError(ctx, err)
}

// Prune branches that can never reach the requested subject type.
it, _, err = query.ApplyReachabilityPruning(it, req.Subject.Object.ObjectType)
if err != nil {
return nil, ps.rewriteError(ctx, err)
}

Check warning on line 70 in internal/services/v1/permissions_queryplan.go

View check run for this annotation

Codecov / codecov/patch

internal/services/v1/permissions_queryplan.go#L67-L70

Added lines #L67 - L70 were not covered by tests
// TODO apply to LR and LS too?

// Parse caveat context if provided
caveatContext, err := GetCaveatContext(ctx, req.Context, ps.config.MaxCaveatContextSize)
if err != nil {
Expand Down
140 changes: 140 additions & 0 deletions pkg/query/optimize_reachability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package query

import "slices"

// ApplyReachabilityPruning applies subject-type reachability pruning to an
// iterator tree, replacing subtrees with empty FixedIterators when they can
// never produce the target subject type.
//
// For arrows (e.g. editor->view), the pruning decision is based on whether the
// right side (computed userset) can reach the target subject type. If not, the
// entire arrow is elided. The left side's subject types are intermediate hops
// and are not considered for pruning.
func ApplyReachabilityPruning(it Iterator, targetSubjectType string) (Iterator, bool, error) {
if it == nil {
return nil, false, nil
}

Check warning on line 16 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L15-L16

Added lines #L15 - L16 were not covered by tests

// For arrows, only recurse into the right child.
if arrow, ok := it.(*ArrowIterator); ok {
return applyReachabilityToArrow(arrow, targetSubjectType)
}
if arrow, ok := it.(*IntersectionArrowIterator); ok {
return applyReachabilityToIntersectionArrow(arrow, targetSubjectType)
}

// For all other iterators, recurse into all children first (bottom-up).
origSubs := it.Subiterators()
changed := false
if len(origSubs) > 0 {
subs := make([]Iterator, len(origSubs))
copy(subs, origSubs)

subChanged := false
for i, sub := range subs {
newSub, ok, err := ApplyReachabilityPruning(sub, targetSubjectType)
if err != nil {
return nil, false, err
}

Check warning on line 38 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L37-L38

Added lines #L37 - L38 were not covered by tests
if ok {
subs[i] = newSub
subChanged = true
}
}
if subChanged {
changed = true
var err error
it, err = it.ReplaceSubiterators(subs)
if err != nil {
return nil, false, err
}

Check warning on line 50 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L49-L50

Added lines #L49 - L50 were not covered by tests
}
}

// Now check this node's subject types.
subjectTypes, err := it.SubjectTypes()
if err != nil || len(subjectTypes) == 0 {
return it, changed, nil
}

if hasMatchingSubjectType(subjectTypes, targetSubjectType) {
return it, changed, nil
}

return newEmptyWithKey(it.CanonicalKey()), true, nil
}

func applyReachabilityToArrow(arrow *ArrowIterator, targetSubjectType string) (Iterator, bool, error) {
// Only recurse into the right child - left side types are intermediates.
newRight, rightChanged, err := ApplyReachabilityPruning(arrow.right, targetSubjectType)
if err != nil {
return nil, false, err
}

Check warning on line 72 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L71-L72

Added lines #L71 - L72 were not covered by tests

if rightChanged {
newArrow, err := arrow.ReplaceSubiterators([]Iterator{arrow.left, newRight})
if err != nil {
return nil, false, err
}

Check warning on line 78 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L77-L78

Added lines #L77 - L78 were not covered by tests
arrow = newArrow.(*ArrowIterator)
}

// Check if the right side can produce the target subject type.
if shouldPruneArrowRight(arrow.right, targetSubjectType, rightChanged) {
return newEmptyWithKey(arrow.CanonicalKey()), true, nil
}
return arrow, rightChanged, nil
}

func applyReachabilityToIntersectionArrow(arrow *IntersectionArrowIterator, targetSubjectType string) (Iterator, bool, error) {
// Only recurse into the right child - left side types are intermediates.
newRight, rightChanged, err := ApplyReachabilityPruning(arrow.right, targetSubjectType)
if err != nil {
return nil, false, err
}

Check warning on line 94 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L93-L94

Added lines #L93 - L94 were not covered by tests

if rightChanged {
newArrow, err := arrow.ReplaceSubiterators([]Iterator{arrow.left, newRight})
if err != nil {
return nil, false, err
}

Check warning on line 100 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L99-L100

Added lines #L99 - L100 were not covered by tests
arrow = newArrow.(*IntersectionArrowIterator)
}

// Check if the right side can produce the target subject type.
if shouldPruneArrowRight(arrow.right, targetSubjectType, rightChanged) {
return newEmptyWithKey(arrow.CanonicalKey()), true, nil
}
return arrow, rightChanged, nil
}

// shouldPruneArrowRight checks whether the right side of an arrow can produce
// the target subject type. Since applyReachabilityToArrow always recurses into
// the right child first, by the time this runs any non-matching leaves have
// already been pruned. So this only needs to check two cases:
// 1. Right side has no subject types after pruning → prune the arrow.
// 2. Right side still has matching subject types → keep the arrow.
func shouldPruneArrowRight(right Iterator, targetSubjectType string, rightAlreadyPruned bool) bool {
rightTypes, err := right.SubjectTypes()
if err != nil {
return false
}

Check warning on line 121 in pkg/query/optimize_reachability.go

View check run for this annotation

Codecov / codecov/patch

pkg/query/optimize_reachability.go#L120-L121

Added lines #L120 - L121 were not covered by tests

if len(rightTypes) == 0 {
return rightAlreadyPruned
}

return !hasMatchingSubjectType(rightTypes, targetSubjectType)
}

func hasMatchingSubjectType(subjectTypes []ObjectType, targetType string) bool {
return slices.ContainsFunc(subjectTypes, func(s ObjectType) bool {
return s.Type == targetType
})
}

func newEmptyWithKey(key CanonicalKey) *FixedIterator {
empty := NewFixedIterator()
empty.canonicalKey = key
return empty
}
Loading
Loading