Low severity2.9NVD Advisory· Published Nov 21, 2025· Updated Nov 24, 2025
SpiceDB's LookupResources with Multiple Entrypoints across Different Definitions Can Return Incomplete Results
CVE-2025-65111
Description
SpiceDB is an open source database system for creating and managing security-critical application permissions. Prior to version 1.47.1, if a schema includes the following characteristics: permission defined in terms of a union (+) and that union references the same relation on both sides (but one side arrows to a different permission). Then SpiceDB may have missing LookupResources results when checking the permission. This only affects LookupResources; other APIs calculate permissionship correctly. The issue is fixed in version 1.47.1.
Affected products
1Patches
18c2edbe1e7bdMerge commit from fork
4 files changed · +164 −21
internal/services/integrationtesting/testconfigs/arrowsublr.yaml+30 −0 added@@ -0,0 +1,30 @@ +schema: |+ + definition special_user {} + + definition user { + relation special_user_mapping: special_user + permission special_user = special_user_mapping + } + + definition group { + relation member: user + permission membership = member + member->special_user + } + + definition system { + relation viewer: user | group#membership + permission view = viewer + viewer->special_user + } + + +relationships: |- + system:somesystem#viewer@group:somegroup#membership + + group:somegroup#member@user:someuser1 + + user:someuser1#special_user_mapping@special_user:specialuser +assertions: + assertTrue: + - system:somesystem#view@special_user:specialuser + assertFalse: [] +validation: {}
pkg/query/build_tree.go+55 −1 modified@@ -1,6 +1,7 @@ package query import ( + "errors" "fmt" core "github.com/authzed/spicedb/pkg/proto/core/v1" @@ -89,7 +90,10 @@ func (b *iteratorBuilder) buildIteratorFromSchemaInternal(definitionName string, } else if r, ok := def.GetRelation(relationName); ok { result, err = b.buildIteratorFromRelation(r, withSubRelations) } else { - err = fmt.Errorf("BuildIteratorFromSchema: couldn't find a relation or permission named `%s` in definition `%s`", relationName, definitionName) + err = RelationNotFoundError{ + definitionName: definitionName, + relationName: relationName, + } } // Remove from building after we're done (allows reuse in other branches) @@ -284,36 +288,76 @@ func (b *iteratorBuilder) buildBaseRelationIterator(br *schema.BaseRelation, wit // buildArrowIterators creates a union of arrow iterators for the given relation and right-hand side func (b *iteratorBuilder) buildArrowIterators(rel *schema.Relation, rightSide string) (Iterator, error) { union := NewUnion() + hasMultipleBaseRelations := len(rel.BaseRelations()) > 1 + var lastNotFoundError error + for _, br := range rel.BaseRelations() { left, err := b.buildBaseRelationIterator(br, false) if err != nil { return nil, err } right, err := b.buildIteratorFromSchemaInternal(br.Type(), rightSide, false) if err != nil { + // If the right side doesn't exist on this type, the arrow produces an empty set. + // This is valid when a relation has multiple types and the arrow only + // applies to some of them. If there's only one base relation, we should error. + if errors.As(err, &RelationNotFoundError{}) { + if hasMultipleBaseRelations { + union.addSubIterator(NewEmptyFixedIterator()) + continue + } + lastNotFoundError = err + continue + } return nil, err } arrow := NewArrow(left, right) union.addSubIterator(arrow) } + + // If we have no sub-iterators and only have a not-found error, return that error + if len(union.Subiterators()) == 0 && lastNotFoundError != nil { + return nil, lastNotFoundError + } + return union, nil } // buildIntersectionArrowIterators creates a union of intersection arrow iterators for the given relation and right-hand side func (b *iteratorBuilder) buildIntersectionArrowIterators(rel *schema.Relation, rightSide string) (Iterator, error) { union := NewUnion() + hasMultipleBaseRelations := len(rel.BaseRelations()) > 1 + var lastNotFoundError error + for _, br := range rel.BaseRelations() { left, err := b.buildBaseRelationIterator(br, false) if err != nil { return nil, err } right, err := b.buildIteratorFromSchemaInternal(br.Type(), rightSide, false) if err != nil { + // If the right side doesn't exist on this type, the intersection arrow produces an empty set. + // This is valid when a relation has multiple types and the arrow only + // applies to some of them. If there's only one base relation, we should error. + if errors.As(err, &RelationNotFoundError{}) { + if hasMultipleBaseRelations { + union.addSubIterator(NewEmptyFixedIterator()) + continue + } + lastNotFoundError = err + continue + } return nil, err } intersectionArrow := NewIntersectionArrow(left, right) union.addSubIterator(intersectionArrow) } + + // If we have no sub-iterators and only have a not-found error, return that error + if len(union.Subiterators()) == 0 && lastNotFoundError != nil { + return nil, lastNotFoundError + } + return union, nil } @@ -327,3 +371,13 @@ func functionTypeString(ft schema.FunctionType) string { return "unknown" } } + +// RelationNotFoundError is returned when a relation or permission is not found in a definition +type RelationNotFoundError struct { + definitionName string + relationName string +} + +func (e RelationNotFoundError) Error() string { + return fmt.Sprintf("BuildIteratorFromSchema: couldn't find a relation or permission named `%s` in definition `%s`", e.relationName, e.definitionName) +}
pkg/schema/reachabilitygraph.go+23 −20 modified@@ -37,7 +37,7 @@ func (rg *DefinitionReachability) RelationsEncounteredForResource( ctx context.Context, resourceType *core.RelationReference, ) ([]*core.RelationReference, error) { - _, relationNames, err := rg.computeEntrypoints(ctx, resourceType, nil /* include all entrypoints */, reachabilityFull, entrypointLookupFindAll) + _, relationNames, err := rg.computeEntrypoints(ctx, resourceType, nil, reachabilityFull /* include all entrypoints */, entrypointLookupFindAll) if err != nil { return nil, err } @@ -85,11 +85,12 @@ func (rg *DefinitionReachability) RelationsEncounteredForSubject( continue } - encounteredRelations := map[string]struct{}{} + allEncounteredRelations := mapz.NewSet[string]() + encounteredRelationsForComputation := mapz.NewSet[string]() err := nrg.collectEntrypoints(ctx, &core.RelationReference{ Namespace: nsDef.Name, Relation: relation.Name, - }, subjectType, collected, encounteredRelations, reachabilityFull, entrypointLookupFindAll) + }, subjectType, collected, allEncounteredRelations, encounteredRelationsForComputation, reachabilityFull, entrypointLookupFindAll) if err != nil { return nil, err } @@ -193,10 +194,13 @@ func (rg *DefinitionReachability) computeEntrypoints( } collected := &[]ReachabilityEntrypoint{} - encounteredRelations := map[string]struct{}{} - err := rg.collectEntrypoints(ctx, resourceType, optionalSubjectType, collected, encounteredRelations, reachabilityOption, entrypointLookupOption) + + allEncounteredRelations := mapz.NewSet[string]() + encounteredRelationsForComputation := mapz.NewSet[string]() + + err := rg.collectEntrypoints(ctx, resourceType, optionalSubjectType, collected, allEncounteredRelations, encounteredRelationsForComputation, reachabilityOption, entrypointLookupOption) if err != nil { - return nil, slices.Collect(maps.Keys(encounteredRelations)), err + return nil, allEncounteredRelations.AsSlice(), err } collectedEntrypoints := *collected @@ -213,7 +217,7 @@ func (rg *DefinitionReachability) computeEntrypoints( for _, entrypoint := range collectedEntrypoints { hash, err := entrypoint.Hash() if err != nil { - return nil, slices.Collect(maps.Keys(encounteredRelations)), err + return nil, allEncounteredRelations.AsSlice(), err } if _, ok := entrypointMap[hash]; !ok { @@ -222,7 +226,7 @@ func (rg *DefinitionReachability) computeEntrypoints( } } - return uniqueEntrypoints, slices.Collect(maps.Keys(encounteredRelations)), nil + return uniqueEntrypoints, allEncounteredRelations.AsSlice(), nil } func (rg *DefinitionReachability) getOrBuildGraph(ctx context.Context, resourceType *core.RelationReference, reachabilityOption reachabilityOption) (*core.ReachabilityGraph, error) { @@ -253,17 +257,17 @@ func (rg *DefinitionReachability) collectEntrypoints( resourceType *core.RelationReference, optionalSubjectType *core.RelationReference, collected *[]ReachabilityEntrypoint, - encounteredRelations map[string]struct{}, + allEncounteredRelations *mapz.Set[string], + encounteredRelationsForComputation *mapz.Set[string], reachabilityOption reachabilityOption, entrypointLookupOption entrypointLookupOption, ) error { // Ensure that we only process each relation once. key := tuple.JoinRelRef(resourceType.Namespace, resourceType.Relation) - if _, ok := encounteredRelations[key]; ok { + if !encounteredRelationsForComputation.Add(key) { return nil } - - encounteredRelations[key] = struct{}{} + allEncounteredRelations.Add(key) rrg, err := rg.getOrBuildGraph(ctx, resourceType, reachabilityOption) if err != nil { @@ -274,7 +278,7 @@ func (rg *DefinitionReachability) collectEntrypoints( // Add subject type entrypoints. subjectTypeEntrypoints, ok := rrg.EntrypointsBySubjectType[optionalSubjectType.Namespace] if ok { - addEntrypoints(subjectTypeEntrypoints, resourceType, collected, encounteredRelations) + addEntrypoints(subjectTypeEntrypoints, resourceType, collected, allEncounteredRelations, encounteredRelationsForComputation) } if entrypointLookupOption == entrypointLookupFindOne && len(*collected) > 0 { @@ -284,7 +288,7 @@ func (rg *DefinitionReachability) collectEntrypoints( // Add subject relation entrypoints. subjectRelationEntrypoints, ok := rrg.EntrypointsBySubjectRelation[tuple.JoinRelRef(optionalSubjectType.Namespace, optionalSubjectType.Relation)] if ok { - addEntrypoints(subjectRelationEntrypoints, resourceType, collected, encounteredRelations) + addEntrypoints(subjectRelationEntrypoints, resourceType, collected, allEncounteredRelations, encounteredRelationsForComputation) } if entrypointLookupOption == entrypointLookupFindOne && len(*collected) > 0 { @@ -293,11 +297,11 @@ func (rg *DefinitionReachability) collectEntrypoints( } else { // Add all entrypoints. for _, entrypoints := range rrg.EntrypointsBySubjectType { - addEntrypoints(entrypoints, resourceType, collected, encounteredRelations) + addEntrypoints(entrypoints, resourceType, collected, allEncounteredRelations, encounteredRelationsForComputation) } for _, entrypoints := range rrg.EntrypointsBySubjectRelation { - addEntrypoints(entrypoints, resourceType, collected, encounteredRelations) + addEntrypoints(entrypoints, resourceType, collected, allEncounteredRelations, encounteredRelationsForComputation) } } @@ -309,7 +313,7 @@ func (rg *DefinitionReachability) collectEntrypoints( for _, entrypointSetKey := range keys { entrypointSet := rrg.EntrypointsBySubjectRelation[entrypointSetKey] if entrypointSet.SubjectRelation != nil && entrypointSet.SubjectRelation.Relation != tuple.Ellipsis { - err := rg.collectEntrypoints(ctx, entrypointSet.SubjectRelation, optionalSubjectType, collected, encounteredRelations, reachabilityOption, entrypointLookupOption) + err := rg.collectEntrypoints(ctx, entrypointSet.SubjectRelation, optionalSubjectType, collected, allEncounteredRelations, encounteredRelationsForComputation, reachabilityOption, entrypointLookupOption) if err != nil { return err } @@ -323,13 +327,12 @@ func (rg *DefinitionReachability) collectEntrypoints( return nil } -func addEntrypoints(entrypoints *core.ReachabilityEntrypoints, parentRelation *core.RelationReference, collected *[]ReachabilityEntrypoint, encounteredRelations map[string]struct{}) { +func addEntrypoints(entrypoints *core.ReachabilityEntrypoints, parentRelation *core.RelationReference, collected *[]ReachabilityEntrypoint, allEncounteredRelations *mapz.Set[string], encounteredRelationsForComputation *mapz.Set[string]) { for _, entrypoint := range entrypoints.Entrypoints { if entrypoint.TuplesetRelation != "" { key := tuple.JoinRelRef(entrypoint.TargetRelation.Namespace, entrypoint.TuplesetRelation) - encounteredRelations[key] = struct{}{} + allEncounteredRelations.Add(key) } - *collected = append(*collected, ReachabilityEntrypoint{entrypoint, parentRelation}) } }
pkg/schema/reachabilitygraph_test.go+56 −0 modified@@ -1180,6 +1180,62 @@ func TestReachabilityGraph(t *testing.T) { rrt("document", "view", false), }, }, + { + "multiple arrows with same starting point", + `definition special_user {} + + definition user { + relation special_user_mapping: special_user + permission special_user = special_user_mapping + } + + definition group { + relation member: user + permission membership = member + member->special_user + } + + definition system { + relation viewer: user | group#membership + permission view = viewer + viewer->special_user + }`, + rr("system", "view"), + rr("special_user", "..."), + []rrtStruct{ + rrt("user", "special_user_mapping", true), + }, + []rrtStruct{ + rrt("user", "special_user_mapping", true), + }, + }, + { + "multiple arrows with same non-terminal starting point", + `definition special_user {} + + definition user { + relation special_user_mapping: special_user + permission special_user = special_user_mapping + } + + definition group { + relation member: user + permission membership = member + member->special_user + } + + definition system { + relation viewer: user | group#membership + permission view = viewer + viewer->special_user + }`, + rr("system", "view"), + rr("user", "special_user"), + []rrtStruct{ + rrt("system", "view", true), + rrt("group", "membership", true), + }, + []rrtStruct{ + rrt("system", "view", true), + rrt("group", "membership", true), + }, + }, } for _, tc := range testCases {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-9m7r-g8hg-x3vrghsaADVISORY
- github.com/authzed/spicedb/commit/8c2edbe1e7bd3851fa2138f4cc344bfde986dcf2ghsax_refsource_MISC
- github.com/authzed/spicedb/security/advisories/GHSA-9m7r-g8hg-x3vrghsax_refsource_CONFIRM
- nvd.nist.gov/vuln/detail/CVE-2025-65111ghsa
News mentions
0No linked articles in our index yet.