Skip to content

Commit 9c4c183

Browse files
authored
fix: improve wildcard support when doing IterSubjects (authzed#2843)
1 parent e20df7c commit 9c4c183

File tree

3 files changed

+289
-4
lines changed

3 files changed

+289
-4
lines changed

pkg/query/datastore.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,23 @@ func (r *RelationIterator) iterSubjectsNormalImpl(ctx *Context, resource Object)
177177
return nil, err
178178
}
179179

180-
return convertRelationSeqToPathSeq(iter.Seq2[tuple.Relationship, error](relIter)), nil
180+
// Filter out wildcard subjects to match the behavior of LookupSubjects. Wildcards are not
181+
// concrete enumerable subjects. They will be expanded to concrete subjects by the wildcard branch.
182+
return FilterWildcardSubjects(convertRelationSeqToPathSeq(iter.Seq2[tuple.Relationship, error](relIter))), nil
181183
}
182184

183185
func (r *RelationIterator) iterSubjectsWildcardImpl(ctx *Context, resource Object) (PathSeq, error) {
184-
filter := datastore.RelationshipsFilter{
186+
// When a relation contains a wildcard (e.g., user:*), it means "all subjects of that type"
187+
// that have ANY relationship with this resource. We enumerate concrete subjects by:
188+
// 1. First checking if a wildcard relationship actually exists for this resource
189+
// 2. If yes, querying for all concrete subjects with relationships to this resource
190+
//
191+
// This avoids doing a full subject enumeration when no wildcard exists (the common case).
192+
// When wildcards do exist, we do 2 queries in this branch, but that's the correct semantic
193+
// behavior - we only enumerate when there's actually a wildcard to expand.
194+
195+
// First, check if there's actually a wildcard relationship for this resource
196+
wildcardFilter := datastore.RelationshipsFilter{
185197
OptionalResourceType: r.base.DefinitionName(),
186198
OptionalResourceIds: []string{resource.ObjectID},
187199
OptionalResourceRelation: r.base.RelationName(),
@@ -194,16 +206,57 @@ func (r *RelationIterator) iterSubjectsWildcardImpl(ctx *Context, resource Objec
194206
},
195207
}
196208

197-
relIter, err := ctx.Reader.QueryRelationships(ctx, filter,
209+
wildcardIter, err := ctx.Reader.QueryRelationships(ctx, wildcardFilter,
198210
options.WithSkipCaveats(r.base.Caveat() == ""),
199211
options.WithSkipExpiration(!r.base.Expiration()),
200212
options.WithQueryShape(queryshape.AllSubjectsForResources),
213+
options.WithLimit(options.LimitOne), // We only need to know if one exists
201214
)
202215
if err != nil {
203216
return nil, err
204217
}
205218

206-
return convertRelationSeqToPathSeq(iter.Seq2[tuple.Relationship, error](relIter)), nil
219+
// Check if any wildcard relationship exists
220+
hasWildcard := false
221+
for _, err := range wildcardIter {
222+
if err != nil {
223+
return nil, err
224+
}
225+
hasWildcard = true
226+
break
227+
}
228+
229+
// If no wildcard relationship exists, return empty - nothing to enumerate
230+
if !hasWildcard {
231+
return EmptyPathSeq(), nil
232+
}
233+
234+
// Wildcard exists, so enumerate all concrete subjects for this resource.
235+
// Note: This may return some of the same subjects as the non-wildcard branch
236+
// (when both wildcard and concrete relationships exist), but the Union will
237+
// deduplicate them.
238+
allSubjectsFilter := datastore.RelationshipsFilter{
239+
OptionalResourceType: r.base.DefinitionName(),
240+
OptionalResourceIds: []string{resource.ObjectID},
241+
OptionalSubjectsSelectors: []datastore.SubjectsSelector{
242+
{
243+
OptionalSubjectType: r.base.Type(),
244+
RelationFilter: r.buildSubjectRelationFilter(),
245+
},
246+
},
247+
}
248+
249+
relIter, err := ctx.Reader.QueryRelationships(ctx, allSubjectsFilter,
250+
options.WithSkipCaveats(r.base.Caveat() == ""),
251+
options.WithSkipExpiration(!r.base.Expiration()),
252+
options.WithQueryShape(queryshape.AllSubjectsForResources),
253+
)
254+
if err != nil {
255+
return nil, err
256+
}
257+
258+
// Filter out wildcard subjects from the results - we only want concrete subjects
259+
return FilterWildcardSubjects(convertRelationSeqToPathSeq(iter.Seq2[tuple.Relationship, error](relIter))), nil
207260
}
208261

209262
func (r *RelationIterator) IterResourcesImpl(ctx *Context, subject ObjectAndRelation) (PathSeq, error) {

pkg/query/path.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,26 @@ func RewriteSubject(seq PathSeq, subject ObjectAndRelation) PathSeq {
383383
}
384384
}
385385
}
386+
387+
// FilterWildcardSubjects filters out any paths with wildcard subjects.
388+
func FilterWildcardSubjects(seq PathSeq) PathSeq {
389+
return func(yield func(Path, error) bool) {
390+
for path, err := range seq {
391+
if err != nil {
392+
if !yield(Path{}, err) {
393+
return
394+
}
395+
continue
396+
}
397+
398+
// Skip wildcard subjects
399+
if path.Subject.ObjectID == tuple.PublicWildcard {
400+
continue
401+
}
402+
403+
if !yield(path, nil) {
404+
return
405+
}
406+
}
407+
}
408+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package query
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/authzed/spicedb/internal/datastore/common"
10+
"github.com/authzed/spicedb/internal/datastore/dsfortesting"
11+
"github.com/authzed/spicedb/internal/datastore/memdb"
12+
"github.com/authzed/spicedb/pkg/datastore"
13+
"github.com/authzed/spicedb/pkg/genutil/slicez"
14+
"github.com/authzed/spicedb/pkg/schema/v2"
15+
"github.com/authzed/spicedb/pkg/schemadsl/compiler"
16+
"github.com/authzed/spicedb/pkg/schemadsl/input"
17+
"github.com/authzed/spicedb/pkg/tuple"
18+
)
19+
20+
// TestIterSubjectsWithWildcard tests that wildcards are properly filtered and expanded
21+
func TestIterSubjectsWithWildcard(t *testing.T) {
22+
t.Parallel()
23+
24+
require := require.New(t)
25+
rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(t, 0, 0, memdb.DisableGC)
26+
require.NoError(err)
27+
28+
// Create a schema with wildcards: relation viewer: user | user:*
29+
schemaText := `
30+
definition user {}
31+
32+
definition resource {
33+
relation viewer: user | user:*
34+
}
35+
`
36+
37+
ctx := context.Background()
38+
39+
// Compile the schema
40+
compiled, err := compiler.Compile(compiler.InputSchema{
41+
Source: input.Source("test"),
42+
SchemaString: schemaText,
43+
}, compiler.AllowUnprefixedObjectType())
44+
require.NoError(err)
45+
46+
// Write the schema
47+
_, err = rawDS.ReadWriteTx(ctx, func(ctx context.Context, rwt datastore.ReadWriteTransaction) error {
48+
return rwt.LegacyWriteNamespaces(ctx, compiled.ObjectDefinitions...)
49+
})
50+
require.NoError(err)
51+
52+
// Write test data:
53+
// - resource:first#viewer@user:* (wildcard)
54+
// - resource:first#viewer@user:concrete (concrete user)
55+
revision, err := common.WriteRelationships(ctx, rawDS, tuple.UpdateOperationCreate,
56+
tuple.MustParse("resource:first#viewer@user:*"),
57+
tuple.MustParse("resource:first#viewer@user:concrete"),
58+
)
59+
require.NoError(err)
60+
61+
// Build schema for querying
62+
dsSchema, err := schema.BuildSchemaFromDefinitions(compiled.ObjectDefinitions, nil)
63+
require.NoError(err)
64+
65+
resourceDef, _ := dsSchema.GetTypeDefinition("resource")
66+
viewerRel, _ := resourceDef.GetRelation("viewer")
67+
68+
// Test the non-wildcard branch (user)
69+
t.Run("NonWildcardBranch", func(t *testing.T) {
70+
t.Parallel()
71+
// The non-wildcard branch should only return concrete subjects, filtering out wildcards
72+
nonWildcardBranch := NewRelationIterator(viewerRel.BaseRelations()[0]) // user (non-wildcard)
73+
74+
queryCtx := NewLocalContext(ctx, WithReader(rawDS.SnapshotReader(revision)))
75+
subjects, err := queryCtx.IterSubjects(nonWildcardBranch, NewObject("resource", "first"))
76+
require.NoError(err)
77+
78+
paths, err := CollectAll(subjects)
79+
require.NoError(err)
80+
81+
// Should only get the concrete user, not the wildcard
82+
require.Len(paths, 1)
83+
require.Equal("user", paths[0].Subject.ObjectType)
84+
require.Equal("concrete", paths[0].Subject.ObjectID)
85+
})
86+
87+
// Test the wildcard branch (user:*)
88+
t.Run("WildcardBranch", func(t *testing.T) {
89+
t.Parallel()
90+
// The wildcard branch should enumerate concrete subjects when a wildcard exists
91+
wildcardBranch := NewRelationIterator(viewerRel.BaseRelations()[1]) // user:* (wildcard)
92+
93+
queryCtx := NewLocalContext(ctx, WithReader(rawDS.SnapshotReader(revision)))
94+
subjects, err := queryCtx.IterSubjects(wildcardBranch, NewObject("resource", "first"))
95+
require.NoError(err)
96+
97+
paths, err := CollectAll(subjects)
98+
require.NoError(err)
99+
100+
// Should only get the concrete user, not the wildcard itself
101+
require.Len(paths, 1)
102+
require.Equal("user", paths[0].Subject.ObjectType)
103+
require.Equal("concrete", paths[0].Subject.ObjectID)
104+
})
105+
106+
// Test the Union (combined behavior)
107+
t.Run("UnionDeduplication", func(t *testing.T) {
108+
t.Parallel()
109+
// The Union of both branches should deduplicate the concrete user
110+
union := NewUnion()
111+
union.addSubIterator(NewRelationIterator(viewerRel.BaseRelations()[0])) // user
112+
union.addSubIterator(NewRelationIterator(viewerRel.BaseRelations()[1])) // user:*
113+
114+
queryCtx := NewLocalContext(ctx, WithReader(rawDS.SnapshotReader(revision)))
115+
subjects, err := queryCtx.IterSubjects(union, NewObject("resource", "first"))
116+
require.NoError(err)
117+
118+
paths, err := CollectAll(subjects)
119+
require.NoError(err)
120+
121+
// Should get exactly one concrete user (deduplicated)
122+
require.Len(paths, 1)
123+
require.Equal("user", paths[0].Subject.ObjectType)
124+
require.Equal("concrete", paths[0].Subject.ObjectID)
125+
})
126+
}
127+
128+
// TestIterSubjectsWildcardWithoutWildcardRelationship tests that the wildcard branch
129+
// returns empty when no wildcard relationship exists
130+
func TestIterSubjectsWildcardWithoutWildcardRelationship(t *testing.T) {
131+
t.Parallel()
132+
133+
require := require.New(t)
134+
rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(t, 0, 0, memdb.DisableGC)
135+
require.NoError(err)
136+
137+
// Create a schema with wildcards: relation viewer: user | user:*
138+
schemaText := `
139+
definition user {}
140+
141+
definition resource {
142+
relation viewer: user | user:*
143+
}
144+
`
145+
146+
ctx := context.Background()
147+
148+
// Compile the schema
149+
compiled, err := compiler.Compile(compiler.InputSchema{
150+
Source: input.Source("test"),
151+
SchemaString: schemaText,
152+
}, compiler.AllowUnprefixedObjectType())
153+
require.NoError(err)
154+
155+
// Write the schema
156+
_, err = rawDS.ReadWriteTx(ctx, func(ctx context.Context, rwt datastore.ReadWriteTransaction) error {
157+
return rwt.LegacyWriteNamespaces(ctx, compiled.ObjectDefinitions...)
158+
})
159+
require.NoError(err)
160+
161+
// Write test data with ONLY concrete users, NO wildcard
162+
revision, err := common.WriteRelationships(ctx, rawDS, tuple.UpdateOperationCreate,
163+
tuple.MustParse("resource:second#viewer@user:alice"),
164+
tuple.MustParse("resource:second#viewer@user:bob"),
165+
)
166+
require.NoError(err)
167+
168+
// Build schema for querying
169+
dsSchema, err := schema.BuildSchemaFromDefinitions(compiled.ObjectDefinitions, nil)
170+
require.NoError(err)
171+
172+
resourceDef, _ := dsSchema.GetTypeDefinition("resource")
173+
viewerRel, _ := resourceDef.GetRelation("viewer")
174+
175+
// Test the wildcard branch when no wildcard relationship exists
176+
t.Run("WildcardBranchWithoutWildcard", func(t *testing.T) {
177+
t.Parallel()
178+
// The wildcard branch should return empty because there's no wildcard relationship
179+
wildcardBranch := NewRelationIterator(viewerRel.BaseRelations()[1]) // user:* (wildcard)
180+
181+
queryCtx := NewLocalContext(ctx, WithReader(rawDS.SnapshotReader(revision)))
182+
subjects, err := queryCtx.IterSubjects(wildcardBranch, NewObject("resource", "second"))
183+
require.NoError(err)
184+
185+
paths, err := CollectAll(subjects)
186+
require.NoError(err)
187+
188+
// Should be empty - no wildcard relationship exists
189+
require.Empty(paths)
190+
})
191+
192+
// Test the non-wildcard branch still works
193+
t.Run("NonWildcardBranchWorksNormally", func(t *testing.T) {
194+
t.Parallel()
195+
nonWildcardBranch := NewRelationIterator(viewerRel.BaseRelations()[0]) // user (non-wildcard)
196+
197+
queryCtx := NewLocalContext(ctx, WithReader(rawDS.SnapshotReader(revision)))
198+
subjects, err := queryCtx.IterSubjects(nonWildcardBranch, NewObject("resource", "second"))
199+
require.NoError(err)
200+
201+
paths, err := CollectAll(subjects)
202+
require.NoError(err)
203+
204+
// Should get both concrete users
205+
require.Len(paths, 2)
206+
subjectIDs := slicez.Map(paths, func(p Path) string { return p.Subject.ObjectID })
207+
require.ElementsMatch([]string{"alice", "bob"}, subjectIDs)
208+
})
209+
}

0 commit comments

Comments
 (0)