diff --git a/internal/datasets/basesubjectset.go b/internal/datasets/basesubjectset.go index 54b0cb6186..efd1e31355 100644 --- a/internal/datasets/basesubjectset.go +++ b/internal/datasets/basesubjectset.go @@ -264,6 +264,25 @@ func (bss BaseSubjectSet[T]) AsSlice() []T { return values } +// SubjectCount returns the number of subjects in the set. +func (bss BaseSubjectSet[T]) SubjectCount() int { + if bss.HasWildcard() { + return bss.ConcreteSubjectCount() + 1 + } + return bss.ConcreteSubjectCount() +} + +// ConcreteSubjectCount returns the number of concrete subjects in the set. +func (bss BaseSubjectSet[T]) ConcreteSubjectCount() int { + return len(bss.concrete) +} + +// HasWildcard returns true if the subject set contains the specialized wildcard subject. +func (bss BaseSubjectSet[T]) HasWildcard() bool { + _, ok := bss.wildcard.get() + return ok +} + // Clone returns a clone of this subject set. Note that this is a shallow clone. // NOTE: Should only be used when performance is not a concern. func (bss BaseSubjectSet[T]) Clone() BaseSubjectSet[T] { diff --git a/internal/datasets/subjectset_test.go b/internal/datasets/subjectset_test.go index 4af11368fb..95ee3802a6 100644 --- a/internal/datasets/subjectset_test.go +++ b/internal/datasets/subjectset_test.go @@ -243,6 +243,13 @@ func TestSubjectSetAdd(t *testing.T) { expectedSet := tc.expectedSet computedSet := existingSet.AsSlice() testutil.RequireEquivalentSets(t, expectedSet, computedSet) + + require.Equal(t, len(expectedSet), existingSet.SubjectCount()) + if existingSet.HasWildcard() { + require.Equal(t, len(expectedSet), existingSet.ConcreteSubjectCount()+1) + } else { + require.Equal(t, len(expectedSet), existingSet.ConcreteSubjectCount()) + } }) } } diff --git a/internal/datasets/subjectsetbyresourceid.go b/internal/datasets/subjectsetbyresourceid.go index 5385d6c305..2699f85841 100644 --- a/internal/datasets/subjectsetbyresourceid.go +++ b/internal/datasets/subjectsetbyresourceid.go @@ -32,6 +32,15 @@ func (ssr SubjectSetByResourceID) add(resourceID string, subject *v1.FoundSubjec return ssr.subjectSetByResourceID[resourceID].Add(subject) } +// ConcreteSubjectCount returns the number concrete subjects in the map. +func (ssr SubjectSetByResourceID) ConcreteSubjectCount() int { + count := 0 + for _, subjectSet := range ssr.subjectSetByResourceID { + count += subjectSet.ConcreteSubjectCount() + } + return count +} + // AddFromRelationship adds the subject found in the given relationship to this map, indexed at // the resource ID specified in the relationship. func (ssr SubjectSetByResourceID) AddFromRelationship(relationship *core.RelationTuple) error { diff --git a/internal/datasets/subjectsetbyresourceid_test.go b/internal/datasets/subjectsetbyresourceid_test.go index 1456309c57..10c22ba6e4 100644 --- a/internal/datasets/subjectsetbyresourceid_test.go +++ b/internal/datasets/subjectsetbyresourceid_test.go @@ -43,6 +43,7 @@ func TestSubjectSetByResourceIDBasicOperations(t *testing.T) { slices.SortFunc(asMap["seconddoc"].FoundSubjects, testutil.CmpSubjects) require.Equal(t, expected, asMap) + require.Equal(t, 3, ssr.ConcreteSubjectCount()) } func TestSubjectSetByResourceIDUnionWith(t *testing.T) { @@ -88,6 +89,8 @@ func TestSubjectSetByResourceIDUnionWith(t *testing.T) { }, }, }, found) + + require.Equal(t, 5, ssr.ConcreteSubjectCount()) } func TestSubjectSetByResourceIDIntersectionDifference(t *testing.T) { diff --git a/internal/datasets/subjectsetbytype.go b/internal/datasets/subjectsetbytype.go index 388eb08799..ae45c99428 100644 --- a/internal/datasets/subjectsetbytype.go +++ b/internal/datasets/subjectsetbytype.go @@ -53,6 +53,25 @@ func (s *SubjectByTypeSet) ForEachType(handler func(rr *core.RelationReference, } } +// ForEachTypeUntil invokes the handler for each type of ObjectAndRelation found in the set, along +// with all IDs of objects of that type, until the handler returns an error or false. +func (s *SubjectByTypeSet) ForEachTypeUntil(handler func(rr *core.RelationReference, subjects SubjectSet) (bool, error)) error { + for key, subjects := range s.byType { + ns, rel := tuple.MustSplitRelRef(key) + ok, err := handler(&core.RelationReference{ + Namespace: ns, + Relation: rel, + }, subjects) + if err != nil { + return err + } + if !ok { + return nil + } + } + return nil +} + // Map runs the mapper function over each type of object in the set, returning a new ONRByTypeSet with // the object type replaced by that returned by the mapper function. func (s *SubjectByTypeSet) Map(mapper func(rr *core.RelationReference) (*core.RelationReference, error)) (*SubjectByTypeSet, error) { diff --git a/internal/datasets/subjectsetbytype_test.go b/internal/datasets/subjectsetbytype_test.go index dbfa5599b4..eedfe0ecc8 100644 --- a/internal/datasets/subjectsetbytype_test.go +++ b/internal/datasets/subjectsetbytype_test.go @@ -34,6 +34,26 @@ func TestSubjectByTypeSet(t *testing.T) { } }) require.True(t, wasFound) + + wasFound = false + err := s.ForEachTypeUntil(func(foundRR *core.RelationReference, subjects SubjectSet) (bool, error) { + objectIds := make([]string, 0, len(subjects.AsSlice())) + for _, subject := range subjects.AsSlice() { + require.Empty(t, subject.GetExcludedSubjects()) + objectIds = append(objectIds, subject.SubjectId) + } + + if rr.Namespace == foundRR.Namespace && rr.Relation == foundRR.Relation { + sort.Strings(objectIds) + require.Equal(t, expected, objectIds) + wasFound = true + return false, nil + } + + return true, nil + }) + require.True(t, wasFound) + require.NoError(t, err) } set := NewSubjectByTypeSet() diff --git a/internal/dispatch/graph/lookupresources_test.go b/internal/dispatch/graph/lookupresources_test.go index fbc73287b4..034a32c3fa 100644 --- a/internal/dispatch/graph/lookupresources_test.go +++ b/internal/dispatch/graph/lookupresources_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "strings" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/authzed/spicedb/internal/dispatch" datastoremw "github.com/authzed/spicedb/internal/middleware/datastore" "github.com/authzed/spicedb/internal/testfixtures" + "github.com/authzed/spicedb/internal/testutil" "github.com/authzed/spicedb/pkg/genutil/mapz" core "github.com/authzed/spicedb/pkg/proto/core/v1" v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" @@ -333,52 +335,15 @@ func TestMaxDepthLookup(t *testing.T) { require.Error(err) } -func joinTuples(first []*core.RelationTuple, second []*core.RelationTuple) []*core.RelationTuple { - return append(first, second...) -} - -func genTuplesWithOffset(resourceName string, relation string, subjectName string, subjectID string, offset int, number int) []*core.RelationTuple { - return genTuplesWithCaveat(resourceName, relation, subjectName, subjectID, "", nil, offset, number) -} - -func genTuples(resourceName string, relation string, subjectName string, subjectID string, number int) []*core.RelationTuple { - return genTuplesWithOffset(resourceName, relation, subjectName, subjectID, 0, number) -} +type OrderedResolved []*v1.ResolvedResource -func genSubjectTuples(resourceName string, relation string, subjectName string, subjectRelation string, number int) []*core.RelationTuple { - tuples := make([]*core.RelationTuple, 0, number) - for i := 0; i < number; i++ { - tpl := &core.RelationTuple{ - ResourceAndRelation: ONR(resourceName, fmt.Sprintf("%s-%d", resourceName, i), relation), - Subject: ONR(subjectName, fmt.Sprintf("%s-%d", subjectName, i), subjectRelation), - } - tuples = append(tuples, tpl) - } - return tuples -} +func (a OrderedResolved) Len() int { return len(a) } -func genTuplesWithCaveat(resourceName string, relation string, subjectName string, subjectID string, caveatName string, context map[string]any, offset int, number int) []*core.RelationTuple { - tuples := make([]*core.RelationTuple, 0, number) - for i := 0; i < number; i++ { - tpl := &core.RelationTuple{ - ResourceAndRelation: ONR(resourceName, fmt.Sprintf("%s-%d", resourceName, i+offset), relation), - Subject: ONR(subjectName, subjectID, "..."), - } - if caveatName != "" { - tpl = tuple.MustWithCaveat(tpl, caveatName, context) - } - tuples = append(tuples, tpl) - } - return tuples +func (a OrderedResolved) Less(i, j int) bool { + return strings.Compare(a[i].ResourceId, a[j].ResourceId) < 0 } -func genResourceIds(resourceName string, number int) []string { - resourceIDs := make([]string, 0, number) - for i := 0; i < number; i++ { - resourceIDs = append(resourceIDs, fmt.Sprintf("%s-%d", resourceName, i)) - } - return resourceIDs -} +func (a OrderedResolved) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { testCases := []struct { @@ -398,13 +363,13 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation viewer: user permission view = viewer + editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 1510), - genTuples("document", "editor", "user", "tom", 1510), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 1510), + testutil.GenTuples("document", "editor", "user", "tom", 1510), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 1510), + testutil.GenResourceIds("document", 1510), }, { "basic exclusion", @@ -415,10 +380,10 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation viewer: user permission view = viewer - banned }`, - genTuples("document", "viewer", "user", "tom", 1010), + testutil.GenTuples("document", "viewer", "user", "tom", 1010), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 1010), + testutil.GenResourceIds("document", 1010), }, { "basic intersection", @@ -429,13 +394,13 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation viewer: user permission view = viewer & editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 510), - genTuples("document", "editor", "user", "tom", 510), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 510), + testutil.GenTuples("document", "editor", "user", "tom", 510), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 510), + testutil.GenResourceIds("document", 510), }, { "union and exclused union", @@ -448,13 +413,13 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { permission can_view = viewer - banned permission view = can_view + editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 1310), - genTuplesWithOffset("document", "editor", "user", "tom", 1250, 1200), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 1310), + testutil.GenTuplesWithOffset("document", "editor", "user", "tom", 1250, 1200), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 2450), + testutil.GenResourceIds("document", 2450), }, { "basic caveats", @@ -468,10 +433,10 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation viewer: user with somecaveat permission view = viewer }`, - genTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{"somecondition": 42}, 0, 2450), + testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{"somecondition": 42}, 0, 2450), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 2450), + testutil.GenResourceIds("document", 2450), }, { "excluded items", @@ -482,13 +447,13 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation viewer: user permission view = viewer - banned }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 1310), - genTuplesWithOffset("document", "banned", "user", "tom", 1210, 100), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 1310), + testutil.GenTuplesWithOffset("document", "banned", "user", "tom", 1210, 100), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 1210), + testutil.GenResourceIds("document", 1210), }, { "basic caveats with missing field", @@ -502,10 +467,10 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation viewer: user with somecaveat permission view = viewer }`, - genTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{}, 0, 2450), + testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{}, 0, 2450), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 2450), + testutil.GenResourceIds("document", 2450), }, { "larger arrow dispatch", @@ -519,13 +484,13 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation folder: folder permission view = folder->viewer }`, - joinTuples( - genTuples("folder", "viewer", "user", "tom", 150), - genSubjectTuples("document", "folder", "folder", "...", 150), + testutil.JoinTuples( + testutil.GenTuples("folder", "viewer", "user", "tom", 150), + testutil.GenSubjectTuples("document", "folder", "folder", "...", 150), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 150), + testutil.GenResourceIds("document", 150), }, { "big", @@ -536,13 +501,13 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { relation viewer: user permission view = viewer + editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 15100), - genTuples("document", "editor", "user", "tom", 15100), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 15100), + testutil.GenTuples("document", "editor", "user", "tom", 15100), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 15100), + testutil.GenResourceIds("document", 15100), }, } diff --git a/internal/dispatch/graph/lookupsubjects_test.go b/internal/dispatch/graph/lookupsubjects_test.go index 88cd8c4bb4..69b06ac820 100644 --- a/internal/dispatch/graph/lookupsubjects_test.go +++ b/internal/dispatch/graph/lookupsubjects_test.go @@ -25,6 +25,7 @@ import ( var ( caveatexpr = caveats.CaveatExprForTesting caveatAnd = caveats.And + caveatOr = caveats.Or caveatInvert = caveats.Invert ) @@ -203,7 +204,7 @@ func TestLookupSubjectsMaxDepth(t *testing.T) { ctx := log.Logger.WithContext(datastoremw.ContextWithHandle(context.Background())) require.NoError(datastoremw.SetInContext(ctx, ds)) - tpl := tuple.Parse("folder:oops#owner@folder:oops#owner") + tpl := tuple.Parse("folder:oops#parent@folder:oops") revision, err := common.WriteTuples(ctx, ds, corev1.RelationTupleUpdate_CREATE, tpl) require.NoError(err) @@ -211,7 +212,7 @@ func TestLookupSubjectsMaxDepth(t *testing.T) { stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupSubjectsResponse](ctx) err = dis.DispatchLookupSubjects(&v1.DispatchLookupSubjectsRequest{ - ResourceRelation: RR("folder", "owner"), + ResourceRelation: RR("folder", "view"), ResourceIds: []string{"oops"}, SubjectRelation: RR("user", "..."), Metadata: &v1.ResolverMeta{ @@ -683,3 +684,820 @@ func TestCaveatedLookupSubjects(t *testing.T) { }) } } + +func TestCursoredLookupSubjects(t *testing.T) { + testCases := []struct { + name string + pageSizes []int + schema string + relationships []*corev1.RelationTuple + start *corev1.ObjectAndRelation + target *corev1.RelationReference + expected []*v1.FoundSubject + }{ + { + "simple", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user + permission view = viewer + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:sarah"), + tuple.MustParse("document:first#viewer@user:fred"), + tuple.MustParse("document:first#viewer@user:tom"), + tuple.MustParse("document:first#viewer@user:andria"), + tuple.MustParse("document:first#viewer@user:victor"), + tuple.MustParse("document:first#viewer@user:chuck"), + tuple.MustParse("document:first#viewer@user:ben"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "tom"}, + {SubjectId: "andria"}, + {SubjectId: "victor"}, + {SubjectId: "chuck"}, + {SubjectId: "ben"}, + }, + }, + { + "basic union", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer1: user + relation viewer2: user + permission view = viewer1 + viewer2 + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer1@user:sarah"), + tuple.MustParse("document:first#viewer1@user:fred"), + tuple.MustParse("document:first#viewer1@user:tom"), + tuple.MustParse("document:first#viewer2@user:andria"), + tuple.MustParse("document:first#viewer2@user:victor"), + tuple.MustParse("document:first#viewer2@user:chuck"), + tuple.MustParse("document:first#viewer2@user:ben"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "tom"}, + {SubjectId: "andria"}, + {SubjectId: "victor"}, + {SubjectId: "chuck"}, + {SubjectId: "ben"}, + }, + }, + { + "basic intersection", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer1: user + relation viewer2: user + permission view = viewer1 & viewer2 + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer1@user:sarah"), + tuple.MustParse("document:first#viewer1@user:fred"), + tuple.MustParse("document:first#viewer1@user:tom"), + tuple.MustParse("document:first#viewer1@user:andria"), + tuple.MustParse("document:first#viewer1@user:victor"), + tuple.MustParse("document:first#viewer2@user:victor"), + tuple.MustParse("document:first#viewer2@user:chuck"), + tuple.MustParse("document:first#viewer2@user:ben"), + tuple.MustParse("document:first#viewer2@user:andria"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "andria"}, + {SubjectId: "victor"}, + }, + }, + { + "basic exclusion", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer1: user + relation viewer2: user + permission view = viewer1 - viewer2 + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer1@user:sarah"), + tuple.MustParse("document:first#viewer1@user:fred"), + tuple.MustParse("document:first#viewer1@user:tom"), + tuple.MustParse("document:first#viewer1@user:andria"), + tuple.MustParse("document:first#viewer1@user:victor"), + tuple.MustParse("document:first#viewer2@user:victor"), + tuple.MustParse("document:first#viewer2@user:chuck"), + tuple.MustParse("document:first#viewer2@user:ben"), + tuple.MustParse("document:first#viewer2@user:andria"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "tom"}, + }, + }, + { + "union over exclusion", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user + relation editor: user + relation banned: user + + permission edit = editor - banned + permission view = viewer + edit + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:sarah"), + tuple.MustParse("document:first#viewer@user:fred"), + + tuple.MustParse("document:first#editor@user:sarah"), + tuple.MustParse("document:first#editor@user:george"), + tuple.MustParse("document:first#editor@user:victor"), + + tuple.MustParse("document:first#banned@user:victor"), + tuple.MustParse("document:first#banned@user:bannedguy"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "george"}, + }, + }, + { + "basic caveated", + []int{0, 1, 2, 5, 100}, + `definition user {} + + caveat somecaveat(somecondition int) { + somecondition == 42 + } + + definition document { + relation viewer: user | user with somecaveat + permission view = viewer + }`, + []*corev1.RelationTuple{ + tuple.MustWithCaveat(tuple.MustParse("document:first#viewer@user:tom"), "somecaveat"), + tuple.MustWithCaveat(tuple.MustParse("document:first#viewer@user:fred"), "somecaveat"), + tuple.MustWithCaveat(tuple.MustParse("document:first#viewer@user:sarah"), "somecaveat"), + tuple.MustParse("document:first#viewer@user:tracy"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tracy", + }, + { + SubjectId: "tom", + CaveatExpression: caveatexpr("somecaveat"), + }, + { + SubjectId: "fred", + CaveatExpression: caveatexpr("somecaveat"), + }, + { + SubjectId: "sarah", + CaveatExpression: caveatexpr("somecaveat"), + }, + }, + }, + { + "union short-circuited caveated", + []int{0, 1, 2, 5, 100}, + `definition user {} + + caveat somecaveat(somecondition int) { + somecondition == 42 + } + + definition document { + relation viewer: user | user with somecaveat + relation editor: user | user with somecaveat + permission view = viewer + editor + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:tom"), + tuple.MustWithCaveat(tuple.MustParse("document:first#editor@user:tom"), "somecaveat"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + }, + }, + }, + { + "intersection caveated", + []int{0, 1, 2, 5, 100}, + `definition user {} + + caveat somecaveat(somecondition int) { + somecondition == 42 + } + + caveat anothercaveat(somecondition int) { + somecondition == 42 + } + + definition document { + relation viewer: user | user with somecaveat + relation editor: user | user with anothercaveat + permission view = viewer & editor + }`, + []*corev1.RelationTuple{ + tuple.MustWithCaveat(tuple.MustParse("document:first#viewer@user:tom"), "somecaveat"), + tuple.MustWithCaveat(tuple.MustParse("document:first#editor@user:tom"), "anothercaveat"), + tuple.MustWithCaveat(tuple.MustParse("document:first#viewer@user:sarah"), "somecaveat"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + CaveatExpression: caveatAnd( + caveatexpr("somecaveat"), + caveatexpr("anothercaveat"), + ), + }, + }, + }, + { + "simple wildcard", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user | user:* + permission view = viewer + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:sarah"), + tuple.MustParse("document:first#viewer@user:fred"), + tuple.MustParse("document:first#viewer@user:tom"), + tuple.MustParse("document:first#viewer@user:andria"), + tuple.MustParse("document:first#viewer@user:victor"), + tuple.MustParse("document:first#viewer@user:chuck"), + tuple.MustParse("document:first#viewer@user:ben"), + tuple.MustParse("document:first#viewer@user:*"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "tom"}, + {SubjectId: "andria"}, + {SubjectId: "victor"}, + {SubjectId: "chuck"}, + {SubjectId: "ben"}, + {SubjectId: "*"}, + }, + }, + { + "intersection with wildcard", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer1: user + relation viewer2: user:* + permission view = viewer1 & viewer2 + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer1@user:sarah"), + tuple.MustParse("document:first#viewer1@user:fred"), + tuple.MustParse("document:first#viewer1@user:tom"), + tuple.MustParse("document:first#viewer1@user:andria"), + tuple.MustParse("document:first#viewer1@user:victor"), + tuple.MustParse("document:first#viewer1@user:chuck"), + tuple.MustParse("document:first#viewer1@user:ben"), + tuple.MustParse("document:first#viewer2@user:*"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "tom"}, + {SubjectId: "andria"}, + {SubjectId: "victor"}, + {SubjectId: "chuck"}, + {SubjectId: "ben"}, + }, + }, + { + "wildcard with exclusions", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user:* + relation banned: user + permission view = viewer - banned + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#banned@user:sarah"), + tuple.MustParse("document:first#banned@user:fred"), + tuple.MustParse("document:first#banned@user:tom"), + tuple.MustParse("document:first#banned@user:andria"), + tuple.MustParse("document:first#banned@user:victor"), + tuple.MustParse("document:first#banned@user:chuck"), + tuple.MustParse("document:first#banned@user:ben"), + tuple.MustParse("document:first#viewer@user:*"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "*", + ExcludedSubjects: []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "tom"}, + {SubjectId: "andria"}, + {SubjectId: "victor"}, + {SubjectId: "chuck"}, + {SubjectId: "ben"}, + }, + }, + }, + }, + { + "canceling exclusions on wildcards", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user + relation banned: user:* + relation banned2: user + permission view = viewer - (banned - banned2) + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:sarah"), + tuple.MustParse("document:first#viewer@user:fred"), + tuple.MustParse("document:first#viewer@user:tom"), + tuple.MustParse("document:first#viewer@user:andria"), + tuple.MustParse("document:first#viewer@user:victor"), + tuple.MustParse("document:first#viewer@user:chuck"), + tuple.MustParse("document:first#viewer@user:ben"), + + tuple.MustParse("document:first#banned@user:*"), + + tuple.MustParse("document:first#banned2@user:andria"), + tuple.MustParse("document:first#banned2@user:tom"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "andria", + }, + { + SubjectId: "tom", + }, + }, + }, + { + "wildcard with many, many exclusions", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user:* + relation banned: user + permission view = viewer - banned + }`, + (func() []*corev1.RelationTuple { + tuples := make([]*corev1.RelationTuple, 0, 201) + tuples = append(tuples, tuple.MustParse("document:first#viewer@user:*")) + for i := 0; i < 200; i++ { + tuples = append(tuples, tuple.MustParse(fmt.Sprintf("document:first#banned@user:u%03d", i))) + } + return tuples + })(), + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "*", + ExcludedSubjects: (func() []*v1.FoundSubject { + fs := make([]*v1.FoundSubject, 0, 200) + for i := 0; i < 200; i++ { + fs = append(fs, &v1.FoundSubject{SubjectId: fmt.Sprintf("u%03d", i)}) + } + return fs + })(), + }, + }, + }, + { + "simple arrow", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition folder { + relation parent: folder + relation viewer: user + permission view = viewer + parent->view + } + + definition document { + relation parent: folder + relation viewer: user + permission view = viewer + parent->view + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:sarah"), + tuple.MustParse("document:first#viewer@user:fred"), + + tuple.MustParse("document:first#parent@folder:somefolder"), + tuple.MustParse("folder:somefolder#viewer@user:victoria"), + tuple.MustParse("folder:somefolder#viewer@user:tommy"), + + tuple.MustParse("folder:somefolder#parent@folder:another"), + tuple.MustParse("folder:another#viewer@user:diana"), + + tuple.MustParse("folder:another#parent@folder:root"), + tuple.MustParse("folder:root#viewer@user:zeus"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "victoria"}, + {SubjectId: "diana"}, + {SubjectId: "tommy"}, + {SubjectId: "zeus"}, + }, + }, + { + "simple indirect", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user | document#viewer + permission view = viewer + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:sarah"), + tuple.MustParse("document:first#viewer@user:fred"), + + tuple.MustParse("document:second#viewer@user:tom"), + tuple.MustParse("document:second#viewer@user:mark"), + + tuple.MustParse("document:first#viewer@document:second#viewer"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + {SubjectId: "sarah"}, + {SubjectId: "fred"}, + {SubjectId: "tom"}, + {SubjectId: "mark"}, + }, + }, + { + "indirect with combined caveat", + []int{0, 1, 2, 5, 100}, + `definition user {} + + caveat somecaveat(some int) { + some == 42 + } + + caveat anothercaveat(some int) { + some == 43 + } + + definition otherresource { + relation viewer: user with anothercaveat + } + + definition document { + relation viewer: user with somecaveat | otherresource#viewer + permission view = viewer + }`, + []*corev1.RelationTuple{ + tuple.MustWithCaveat(tuple.MustParse("document:first#viewer@user:tom"), "somecaveat"), + + tuple.MustWithCaveat(tuple.MustParse("otherresource:second#viewer@user:tom"), "anothercaveat"), + + tuple.MustParse("document:first#viewer@otherresource:second#viewer"), + }, + ONR("document", "first", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + CaveatExpression: caveatOr( + caveatexpr("somecaveat"), + caveatexpr("anothercaveat"), + ), + }, + }, + }, + { + "indirect with combined caveat direct", + []int{0, 1, 2, 5, 100}, + `definition user {} + + caveat somecaveat(some int) { + some == 42 + } + + caveat anothercaveat(some int) { + some == 43 + } + + definition otherresource { + relation viewer: user with anothercaveat + } + + definition document { + relation viewer: user with somecaveat | otherresource#viewer + permission view = viewer + }`, + []*corev1.RelationTuple{ + tuple.MustWithCaveat(tuple.MustParse("document:first#viewer@user:tom"), "somecaveat"), + + tuple.MustWithCaveat(tuple.MustParse("otherresource:second#viewer@user:tom"), "anothercaveat"), + + tuple.MustParse("document:first#viewer@otherresource:second#viewer"), + }, + ONR("document", "first", "viewer"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + CaveatExpression: caveatOr( + caveatexpr("somecaveat"), + caveatexpr("anothercaveat"), + ), + }, + }, + }, + { + "non-terminal subject", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition document { + relation viewer: user | document#viewer + permission view = viewer + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#viewer@user:sarah"), + tuple.MustParse("document:first#viewer@user:fred"), + + tuple.MustParse("document:second#viewer@user:tom"), + tuple.MustParse("document:second#viewer@user:mark"), + + tuple.MustParse("document:first#viewer@document:second#viewer"), + }, + ONR("document", "first", "view"), + RR("document", "viewer"), + []*v1.FoundSubject{ + {SubjectId: "first"}, + {SubjectId: "second"}, + }, + }, + { + "indirect non-terminal subject", + []int{0, 1, 2, 5, 100}, + `definition user {} + + definition folder { + relation parent_view: folder#view + relation viewer: user + permission view = viewer + parent_view + } + + definition document { + relation parent_view: folder#view + relation viewer: user + permission view = viewer + parent_view + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:first#parent_view@folder:somefolder#view"), + tuple.MustParse("folder:somefolder#parent_view@folder:anotherfolder#view"), + }, + ONR("document", "first", "view"), + RR("folder", "view"), + []*v1.FoundSubject{ + {SubjectId: "anotherfolder"}, + {SubjectId: "somefolder"}, + }, + }, + { + "large direct", + []int{0, 100, 104, 503, 1012, 10056}, + `definition user {} + + definition document { + relation viewer: user + permission view = viewer + }`, + (func() []*corev1.RelationTuple { + tuples := make([]*corev1.RelationTuple, 0, 20000) + for i := 0; i < 20000; i++ { + tuples = append(tuples, tuple.MustParse(fmt.Sprintf("document:first#viewer@user:u%03d", i))) + } + return tuples + })(), + ONR("document", "first", "view"), + RR("user", "..."), + (func() []*v1.FoundSubject { + fs := make([]*v1.FoundSubject, 0, 20000) + for i := 0; i < 20000; i++ { + fs = append(fs, &v1.FoundSubject{SubjectId: fmt.Sprintf("u%03d", i)}) + } + return fs + })(), + }, + { + "large with intersection", + []int{0, 100, 104, 503, 1012, 10056}, + `definition user {} + + definition document { + relation viewer1: user + relation viewer2: user + permission view = viewer1 & viewer2 + }`, + (func() []*corev1.RelationTuple { + tuples := make([]*corev1.RelationTuple, 0, 20000) + for i := 0; i < 20000; i++ { + tuples = append(tuples, tuple.MustParse(fmt.Sprintf("document:first#viewer1@user:u%03d", i))) + tuples = append(tuples, tuple.MustParse(fmt.Sprintf("document:first#viewer2@user:u%03d", i))) + } + return tuples + })(), + ONR("document", "first", "view"), + RR("user", "..."), + (func() []*v1.FoundSubject { + fs := make([]*v1.FoundSubject, 0, 20000) + for i := 0; i < 20000; i++ { + fs = append(fs, &v1.FoundSubject{SubjectId: fmt.Sprintf("u%03d", i)}) + } + return fs + })(), + }, + { + "large with partial intersection", + []int{0, 100, 104, 503, 1012, 10056}, + `definition user {} + + definition document { + relation viewer1: user + relation viewer2: user + permission view = viewer1 & viewer2 + }`, + (func() []*corev1.RelationTuple { + tuples := make([]*corev1.RelationTuple, 0, 20000) + for i := 0; i < 20000; i++ { + tuples = append(tuples, tuple.MustParse(fmt.Sprintf("document:first#viewer1@user:u%03d", i))) + + if i >= 10000 { + tuples = append(tuples, tuple.MustParse(fmt.Sprintf("document:first#viewer2@user:u%03d", i))) + } + } + return tuples + })(), + ONR("document", "first", "view"), + RR("user", "..."), + (func() []*v1.FoundSubject { + fs := make([]*v1.FoundSubject, 0, 10000) + for i := 10000; i < 20000; i++ { + fs = append(fs, &v1.FoundSubject{SubjectId: fmt.Sprintf("u%03d", i)}) + } + return fs + })(), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + for _, limit := range tc.pageSizes { + t.Run(fmt.Sprintf("limit-%d_", limit), func(t *testing.T) { + require := require.New(t) + + dispatcher := NewLocalOnlyDispatcher(10) + + ds, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC) + require.NoError(err) + + ds, revision := testfixtures.DatastoreFromSchemaAndTestRelationships(ds, tc.schema, tc.relationships, require) + + ctx := datastoremw.ContextWithHandle(context.Background()) + require.NoError(datastoremw.SetInContext(ctx, ds)) + + var cursor *v1.Cursor + overallResults := []*v1.FoundSubject{} + + iterCount := 1 + if limit > 0 { + iterCount = (len(tc.expected) / limit) + 1 + } + + for i := 0; i < iterCount; i++ { + stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupSubjectsResponse](ctx) + err = dispatcher.DispatchLookupSubjects(&v1.DispatchLookupSubjectsRequest{ + ResourceRelation: &corev1.RelationReference{ + Namespace: tc.start.Namespace, + Relation: tc.start.Relation, + }, + ResourceIds: []string{tc.start.ObjectId}, + SubjectRelation: tc.target, + Metadata: &v1.ResolverMeta{ + AtRevision: revision.String(), + DepthRemaining: 50, + }, + OptionalLimit: uint32(limit), + OptionalCursor: cursor, + }, stream) + require.NoError(err) + + results := []*v1.FoundSubject{} + hasWildcard := false + + for _, streamResult := range stream.Results() { + for _, foundSubjects := range streamResult.FoundSubjectsByResourceId { + results = append(results, foundSubjects.FoundSubjects...) + for _, fs := range foundSubjects.FoundSubjects { + if fs.SubjectId == tuple.PublicWildcard { + hasWildcard = true + } + } + } + cursor = streamResult.AfterResponseCursor + } + + if limit > 0 { + // If there is a wildcard, its allowed to bypass the limit. + if hasWildcard { + require.LessOrEqual(len(results), limit+1) + } else { + require.LessOrEqual(len(results), limit) + } + } + + overallResults = append(overallResults, results...) + } + + // NOTE: since cursored LS now can return a wildcard multiple times, we need to combine + // them here before comparison. + normalizedResults := combineWildcards(overallResults) + itestutil.RequireEquivalentSets(t, tc.expected, normalizedResults) + }) + } + }) + } +} + +func combineWildcards(results []*v1.FoundSubject) []*v1.FoundSubject { + combined := make([]*v1.FoundSubject, 0, len(results)) + var wildcardResult *v1.FoundSubject + for _, result := range results { + if result.SubjectId != tuple.PublicWildcard { + combined = append(combined, result) + continue + } + + if wildcardResult == nil { + wildcardResult = result + combined = append(combined, result) + continue + } + + wildcardResult.ExcludedSubjects = append(wildcardResult.ExcludedSubjects, result.ExcludedSubjects...) + } + return combined +} diff --git a/internal/dispatch/graph/reachableresources_test.go b/internal/dispatch/graph/reachableresources_test.go index e1b1af6054..9336a0eeb1 100644 --- a/internal/dispatch/graph/reachableresources_test.go +++ b/internal/dispatch/graph/reachableresources_test.go @@ -21,6 +21,7 @@ import ( log "github.com/authzed/spicedb/internal/logging" datastoremw "github.com/authzed/spicedb/internal/middleware/datastore" "github.com/authzed/spicedb/internal/testfixtures" + "github.com/authzed/spicedb/internal/testutil" "github.com/authzed/spicedb/pkg/datastore" "github.com/authzed/spicedb/pkg/datastore/options" "github.com/authzed/spicedb/pkg/genutil/mapz" @@ -1021,13 +1022,13 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation viewer: user permission view = viewer + editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 1510), - genTuples("document", "editor", "user", "tom", 1510), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 1510), + testutil.GenTuples("document", "editor", "user", "tom", 1510), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 1510), + testutil.GenResourceIds("document", 1510), }, { "basic exclusion", @@ -1038,10 +1039,10 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation viewer: user permission view = viewer - banned }`, - genTuples("document", "viewer", "user", "tom", 1010), + testutil.GenTuples("document", "viewer", "user", "tom", 1010), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 1010), + testutil.GenResourceIds("document", 1010), }, { "basic intersection", @@ -1052,13 +1053,13 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation viewer: user permission view = viewer & editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 510), - genTuples("document", "editor", "user", "tom", 510), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 510), + testutil.GenTuples("document", "editor", "user", "tom", 510), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 510), + testutil.GenResourceIds("document", 510), }, { "union and exclused union", @@ -1071,13 +1072,13 @@ func TestReachableResourcesOverSchema(t *testing.T) { permission can_view = viewer - banned permission view = can_view + editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 1310), - genTuplesWithOffset("document", "editor", "user", "tom", 1250, 1200), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 1310), + testutil.GenTuplesWithOffset("document", "editor", "user", "tom", 1250, 1200), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 2450), + testutil.GenResourceIds("document", 2450), }, { "basic caveats", @@ -1091,10 +1092,10 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation viewer: user with somecaveat permission view = viewer }`, - genTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{"somecondition": 42}, 0, 2450), + testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{"somecondition": 42}, 0, 2450), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 2450), + testutil.GenResourceIds("document", 2450), }, { "excluded items", @@ -1105,13 +1106,13 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation viewer: user permission view = viewer - banned }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 1310), - genTuplesWithOffset("document", "banned", "user", "tom", 1210, 100), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 1310), + testutil.GenTuplesWithOffset("document", "banned", "user", "tom", 1210, 100), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 1310), + testutil.GenResourceIds("document", 1310), }, { "basic caveats with missing field", @@ -1125,10 +1126,10 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation viewer: user with somecaveat permission view = viewer }`, - genTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{}, 0, 2450), + testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{}, 0, 2450), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 2450), + testutil.GenResourceIds("document", 2450), }, { "larger arrow dispatch", @@ -1142,13 +1143,13 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation folder: folder permission view = folder->viewer }`, - joinTuples( - genTuples("folder", "viewer", "user", "tom", 150), - genSubjectTuples("document", "folder", "folder", "...", 150), + testutil.JoinTuples( + testutil.GenTuples("folder", "viewer", "user", "tom", 150), + testutil.GenSubjectTuples("document", "folder", "folder", "...", 150), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 150), + testutil.GenResourceIds("document", 150), }, { "big", @@ -1159,13 +1160,13 @@ func TestReachableResourcesOverSchema(t *testing.T) { relation viewer: user permission view = viewer + editor }`, - joinTuples( - genTuples("document", "viewer", "user", "tom", 15100), - genTuples("document", "editor", "user", "tom", 15100), + testutil.JoinTuples( + testutil.GenTuples("document", "viewer", "user", "tom", 15100), + testutil.GenTuples("document", "editor", "user", "tom", 15100), ), RR("document", "view"), ONR("user", "tom", "..."), - genResourceIds("document", 15100), + testutil.GenResourceIds("document", 15100), }, { "chunked arrow with chunked redispatch", diff --git a/internal/dispatch/keys/computed.go b/internal/dispatch/keys/computed.go index 38c9a3a732..2c5e0c5489 100644 --- a/internal/dispatch/keys/computed.go +++ b/internal/dispatch/keys/computed.go @@ -101,5 +101,7 @@ func lookupSubjectsRequestToKey(req *v1.DispatchLookupSubjectsRequest, option di hashableRelationReference{req.ResourceRelation}, hashableRelationReference{req.SubjectRelation}, hashableIds(req.ResourceIds), + hashableCursor{req.OptionalCursor}, + hashableLimit(req.OptionalLimit), ) } diff --git a/internal/dispatch/keys/computed_test.go b/internal/dispatch/keys/computed_test.go index 8af141e99f..5ae641f939 100644 --- a/internal/dispatch/keys/computed_test.go +++ b/internal/dispatch/keys/computed_test.go @@ -403,7 +403,71 @@ func TestStableCacheKeys(t *testing.T) { }, }, computeBothHashes) }, - "d699c5b5d3a6dfade601", + "c2b2d3fcb3aa94f5a801", + }, + { + "lookup subjects with default limit", + func() DispatchCacheKey { + return lookupSubjectsRequestToKey(&v1.DispatchLookupSubjectsRequest{ + ResourceRelation: RR("document", "view"), + SubjectRelation: RR("user", "..."), + ResourceIds: []string{"mariah", "tom"}, + Metadata: &v1.ResolverMeta{ + AtRevision: "1234", + }, + OptionalLimit: 0, + }, computeBothHashes) + }, + "c2b2d3fcb3aa94f5a801", + }, + { + "lookup subjects with different limit", + func() DispatchCacheKey { + return lookupSubjectsRequestToKey(&v1.DispatchLookupSubjectsRequest{ + ResourceRelation: RR("document", "view"), + SubjectRelation: RR("user", "..."), + ResourceIds: []string{"mariah", "tom"}, + Metadata: &v1.ResolverMeta{ + AtRevision: "1234", + }, + OptionalLimit: 10, + }, computeBothHashes) + }, + "ca98fbc58abac8983b", + }, + { + "lookup subjects with cursor", + func() DispatchCacheKey { + return lookupSubjectsRequestToKey(&v1.DispatchLookupSubjectsRequest{ + ResourceRelation: RR("document", "view"), + SubjectRelation: RR("user", "..."), + ResourceIds: []string{"mariah", "tom"}, + Metadata: &v1.ResolverMeta{ + AtRevision: "1234", + }, + OptionalCursor: &v1.Cursor{ + Sections: []string{"foo", "bar"}, + }, + }, computeBothHashes) + }, + "e7d38be4d395cfc3fc01", + }, + { + "lookup subjects with different cursor", + func() DispatchCacheKey { + return lookupSubjectsRequestToKey(&v1.DispatchLookupSubjectsRequest{ + ResourceRelation: RR("document", "view"), + SubjectRelation: RR("user", "..."), + ResourceIds: []string{"mariah", "tom"}, + Metadata: &v1.ResolverMeta{ + AtRevision: "1234", + }, + OptionalCursor: &v1.Cursor{ + Sections: []string{"foo", "baz"}, + }, + }, computeBothHashes) + }, + "fccbc38e9cdbcc8cf901", }, } diff --git a/internal/graph/cursors.go b/internal/graph/cursors.go index 8b6560e28c..fe800f34dd 100644 --- a/internal/graph/cursors.go +++ b/internal/graph/cursors.go @@ -136,8 +136,6 @@ func (ci cursorInformation) clearIncoming() cursorInformation { } } -type cursorHandler func(c cursorInformation) error - // itemAndPostCursor represents an item and the cursor to be used for all items after it. type itemAndPostCursor[T any] struct { item T @@ -208,7 +206,10 @@ func withDatastoreCursorInCursor[T any, Q any]( ) } -type afterResponseCursor func(nextOffset int) *v1.Cursor +type ( + afterResponseCursor func(nextOffset int) *v1.Cursor + cursorHandler func(c cursorInformation) error +) // withSubsetInCursor executes the given handler with the offset index found at the beginning of the // cursor. If the offset is not found, executes with 0. The handler is given the current offset as diff --git a/internal/graph/limits.go b/internal/graph/limits.go index 40fe874db7..573678d8a0 100644 --- a/internal/graph/limits.go +++ b/internal/graph/limits.go @@ -1,13 +1,9 @@ package graph import ( - "fmt" - "github.com/authzed/spicedb/pkg/spiceerrors" ) -var ErrLimitReached = fmt.Errorf("limit has been reached") - // limitTracker is a helper struct for tracking the limit requested by a caller and decrementing // that limit as results are published. type limitTracker struct { diff --git a/internal/graph/lookupsubjects.go b/internal/graph/lookupsubjects.go index e2c03ddd84..2267ca21dc 100644 --- a/internal/graph/lookupsubjects.go +++ b/internal/graph/lookupsubjects.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "golang.org/x/sync/errgroup" @@ -13,13 +14,41 @@ import ( datastoremw "github.com/authzed/spicedb/internal/middleware/datastore" "github.com/authzed/spicedb/internal/namespace" "github.com/authzed/spicedb/pkg/datastore" + "github.com/authzed/spicedb/pkg/datastore/options" "github.com/authzed/spicedb/pkg/genutil/mapz" "github.com/authzed/spicedb/pkg/genutil/slicez" core "github.com/authzed/spicedb/pkg/proto/core/v1" v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" + "github.com/authzed/spicedb/pkg/spiceerrors" "github.com/authzed/spicedb/pkg/tuple" + "github.com/authzed/spicedb/pkg/typesystem" ) +// lsDispatchVersion defines the "version" of this dispatcher. Must be incremented +// anytime an incompatible change is made to the dispatcher itself or its cursor +// production. +const lsDispatchVersion = 1 + +// CursorForFoundSubjectID returns an updated version of the afterResponseCursor (which must have been created +// by this dispatcher), but with the specified subjectID as the starting point. +func CursorForFoundSubjectID(subjectID string, afterResponseCursor *v1.Cursor) (*v1.Cursor, error) { + if afterResponseCursor == nil { + return &v1.Cursor{ + DispatchVersion: lsDispatchVersion, + Sections: []string{subjectID}, + }, nil + } + + if len(afterResponseCursor.Sections) != 1 { + return nil, spiceerrors.MustBugf("given an invalid afterResponseCursor (wrong number of sections)") + } + + return &v1.Cursor{ + DispatchVersion: lsDispatchVersion, + Sections: []string{subjectID}, + }, nil +} + // ValidatedLookupSubjectsRequest represents a request after it has been validated and parsed for internal // consumption. type ValidatedLookupSubjectsRequest struct { @@ -32,6 +61,7 @@ func NewConcurrentLookupSubjects(d dispatch.LookupSubjects, concurrencyLimit uin return &ConcurrentLookupSubjects{d, concurrencyLimit} } +// ConcurrentLookupSubjects performs the concurrent lookup subjects operation. type ConcurrentLookupSubjects struct { d dispatch.LookupSubjects concurrencyLimit uint16 @@ -47,39 +77,94 @@ func (cl *ConcurrentLookupSubjects) LookupSubjects( return fmt.Errorf("no resources ids given to lookupsubjects dispatch") } - // If the resource type matches the subject type, yield directly. - if req.SubjectRelation.Namespace == req.ResourceRelation.Namespace && - req.SubjectRelation.Relation == req.ResourceRelation.Relation { - if err := stream.Publish(&v1.DispatchLookupSubjectsResponse{ - FoundSubjectsByResourceId: subjectsForConcreteIds(req.ResourceIds), - Metadata: emptyMetadata, - }); err != nil { - return err - } + limits := newLimitTracker(req.OptionalLimit) + ci, err := newCursorInformation(req.OptionalCursor, limits, lsDispatchVersion) + if err != nil { + return err + } + + // Run both "branches" in parallel and union together to respect the cursors and limits. + return runInParallel(ctx, ci, stream, cl.concurrencyLimit, + unionOperation{ + callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error { + return cl.yieldMatchingResources(ctx, ci.withClonedLimits(), req, cstream) + }, + runIf: req.SubjectRelation.Namespace == req.ResourceRelation.Namespace && req.SubjectRelation.Relation == req.ResourceRelation.Relation, + }, + unionOperation{ + callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error { + return cl.yieldRelationSubjects(ctx, ci.withClonedLimits(), req, cstream, concurrencyLimit) + }, + runIf: true, + }, + ) +} + +// yieldMatchingResources yields the current resource IDs iff the resource matches the target +// subject. +func (cl *ConcurrentLookupSubjects) yieldMatchingResources( + _ context.Context, + ci cursorInformation, + req ValidatedLookupSubjectsRequest, + stream dispatch.LookupSubjectsStream, +) error { + if req.SubjectRelation.Namespace != req.ResourceRelation.Namespace || + req.SubjectRelation.Relation != req.ResourceRelation.Relation { + return nil } + subjectsMap, err := subjectsForConcreteIds(req.ResourceIds, ci) + if err != nil { + return err + } + + return publishSubjects(stream, ci, subjectsMap) +} + +// yieldRelationSubjects walks the relation, performing lookup subjects on the relation's data or +// computed rewrite. +func (cl *ConcurrentLookupSubjects) yieldRelationSubjects( + ctx context.Context, + ci cursorInformation, + req ValidatedLookupSubjectsRequest, + stream dispatch.LookupSubjectsStream, + concurrencyLimit uint16, +) error { ds := datastoremw.MustFromContext(ctx) reader := ds.SnapshotReader(req.Revision) - _, relation, err := namespace.ReadNamespaceAndRelation( - ctx, - req.ResourceRelation.Namespace, - req.ResourceRelation.Relation, - reader) + + _, validatedTS, err := typesystem.ReadNamespaceAndTypes(ctx, req.ResourceRelation.Namespace, reader) + if err != nil { + return err + } + + relation, err := validatedTS.GetRelationOrError(req.ResourceRelation.Relation) if err != nil { return err } if relation.UsersetRewrite == nil { - // Direct lookup of subjects. - return cl.lookupDirectSubjects(ctx, req, stream, relation, reader) + // As there is no rewrite here, perform direct lookup of subjects on the relation. + return cl.lookupDirectSubjects(ctx, ci, req, stream, validatedTS, reader, concurrencyLimit) } - return cl.lookupViaRewrite(ctx, req, stream, relation.UsersetRewrite) + return cl.lookupViaRewrite(ctx, ci, req, stream, relation.UsersetRewrite, concurrencyLimit) } -func subjectsForConcreteIds(subjectIds []string) map[string]*v1.FoundSubjects { - foundSubjects := make(map[string]*v1.FoundSubjects, len(subjectIds)) - for _, subjectID := range subjectIds { +// subjectsForConcreteIds returns a FoundSubjects map for the given *concrete* subject IDs, filtered by the cursor (if applicable). +func subjectsForConcreteIds(subjectIDs []string, ci cursorInformation) (map[string]*v1.FoundSubjects, error) { + // If the after subject ID is the wildcard, then no concrete subjects should be returned. + afterSubjectID, _ := ci.headSectionValue() + if afterSubjectID == tuple.PublicWildcard { + return nil, nil + } + + foundSubjects := make(map[string]*v1.FoundSubjects, len(subjectIDs)) + for _, subjectID := range subjectIDs { + if afterSubjectID != "" && subjectID <= afterSubjectID { + continue + } + foundSubjects[subjectID] = &v1.FoundSubjects{ FoundSubjects: []*v1.FoundSubject{ { @@ -89,21 +174,189 @@ func subjectsForConcreteIds(subjectIds []string) map[string]*v1.FoundSubjects { }, } } - return foundSubjects + return foundSubjects, nil } +// lookupDirectSubjects performs lookup of subjects directly on a relation. func (cl *ConcurrentLookupSubjects) lookupDirectSubjects( ctx context.Context, + ci cursorInformation, + req ValidatedLookupSubjectsRequest, + stream dispatch.LookupSubjectsStream, + validatedTS *typesystem.ValidatedNamespaceTypeSystem, + reader datastore.Reader, + concurrencyLimit uint16, +) error { + // Check if the direct subject can be found on this relation and, if so, query for then. + directAllowed, err := validatedTS.IsAllowedDirectRelation(req.ResourceRelation.Relation, req.SubjectRelation.Namespace, req.SubjectRelation.Relation) + if err != nil { + return err + } + + hasIndirectSubjects, err := validatedTS.HasIndirectSubjects(req.ResourceRelation.Relation) + if err != nil { + return err + } + + wildcardAllowed, err := validatedTS.IsAllowedPublicNamespace(req.ResourceRelation.Relation, req.SubjectRelation.Namespace) + if err != nil { + return err + } + + return runInParallel(ctx, ci, stream, concurrencyLimit, + // Direct subjects found on the relation. + unionOperation{ + callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error { + return cl.lookupDirectSubjectsForRelation(ctx, ci.withClonedLimits(), req, cstream, validatedTS, reader) + }, + runIf: directAllowed == typesystem.DirectRelationValid, + }, + + // Wildcard on the relation. + unionOperation{ + callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error { + return cl.lookupWildcardSubjectForRelation(ctx, ci.withClonedLimits(), req, cstream, validatedTS, reader) + }, + + // Wildcards are only applicable on ellipsis subjects + runIf: req.SubjectRelation.Relation == tuple.Ellipsis && wildcardAllowed == typesystem.PublicSubjectAllowed, + }, + + // Dispatching over indirect subjects on the relation. + unionOperation{ + callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error { + return cl.dispatchIndirectSubjectsForRelation(ctx, ci.withClonedLimits(), req, cstream, reader) + }, + runIf: hasIndirectSubjects, + }, + ) +} + +// lookupDirectSubjectsForRelation finds all directly matching subjects on the request's relation, if applicable. +func (cl *ConcurrentLookupSubjects) lookupDirectSubjectsForRelation( + ctx context.Context, + ci cursorInformation, + req ValidatedLookupSubjectsRequest, + stream dispatch.LookupSubjectsStream, + validatedTS *typesystem.ValidatedNamespaceTypeSystem, + reader datastore.Reader, +) error { + // Check if the direct subject can be found on this relation and, if so, query for then. + directAllowed, err := validatedTS.IsAllowedDirectRelation(req.ResourceRelation.Relation, req.SubjectRelation.Namespace, req.SubjectRelation.Relation) + if err != nil { + return err + } + + if directAllowed == typesystem.DirectRelationNotValid { + return nil + } + + var afterCursor options.Cursor + afterSubjectID, _ := ci.headSectionValue() + + // If the cursor specifies the wildcard, then skip all further non-wildcard results. + if afterSubjectID == tuple.PublicWildcard { + return nil + } + + if afterSubjectID != "" { + afterCursor = &core.RelationTuple{ + // NOTE: since we fully specify the resource below, the resource should be ignored in this cursor. + ResourceAndRelation: &core.ObjectAndRelation{ + Namespace: "", + ObjectId: "", + Relation: "", + }, + Subject: &core.ObjectAndRelation{ + Namespace: req.SubjectRelation.Namespace, + ObjectId: afterSubjectID, + Relation: req.SubjectRelation.Relation, + }, + } + } + + limit := ci.limits.currentLimit + 1 // +1 because there might be a matching wildcard too. + if !ci.limits.hasLimit { + limit = 0 + } + + foundSubjectsByResourceID := datasets.NewSubjectSetByResourceID() + if err := queryForDirectSubjects(ctx, req, datastore.SubjectsSelector{ + OptionalSubjectType: req.SubjectRelation.Namespace, + RelationFilter: datastore.SubjectRelationFilter{}.WithNonEllipsisRelation(req.SubjectRelation.Relation), + }, afterCursor, foundSubjectsByResourceID, reader, limit); err != nil { + return err + } + + // Send the results to the stream. + if foundSubjectsByResourceID.IsEmpty() { + return nil + } + return publishSubjects(stream, ci, foundSubjectsByResourceID.AsMap()) +} + +// lookupWildcardSubjectForRelation finds the wildcard subject on the request's relation, if applicable. +func (cl *ConcurrentLookupSubjects) lookupWildcardSubjectForRelation( + ctx context.Context, + ci cursorInformation, + req ValidatedLookupSubjectsRequest, + stream dispatch.LookupSubjectsStream, + validatedTS *typesystem.ValidatedNamespaceTypeSystem, + reader datastore.Reader, +) error { + // Check if a wildcard is possible and, if so, query directly for it without any cursoring. This is necessary because wildcards + // must *always* be returned, regardless of the cursor. + if req.SubjectRelation.Relation != tuple.Ellipsis { + return nil + } + + wildcardAllowed, err := validatedTS.IsAllowedPublicNamespace(req.ResourceRelation.Relation, req.SubjectRelation.Namespace) + if err != nil { + return err + } + if wildcardAllowed == typesystem.PublicSubjectNotAllowed { + return nil + } + + // NOTE: the cursor here is `nil` regardless of that passed in, to ensure wildcards are always returned. + foundSubjectsByResourceID := datasets.NewSubjectSetByResourceID() + if err := queryForDirectSubjects(ctx, req, datastore.SubjectsSelector{ + OptionalSubjectType: req.SubjectRelation.Namespace, + OptionalSubjectIds: []string{tuple.PublicWildcard}, + RelationFilter: datastore.SubjectRelationFilter{}.WithEllipsisRelation(), + }, nil, foundSubjectsByResourceID, reader, 1); err != nil { + return err + } + + // Send the results to the stream. + if foundSubjectsByResourceID.IsEmpty() { + return nil + } + + return publishSubjects(stream, ci, foundSubjectsByResourceID.AsMap()) +} + +// dispatchIndirectSubjectsForRelation looks up all non-ellipsis subjects on the relation and redispatches the LookupSubjects +// operation over them. +func (cl *ConcurrentLookupSubjects) dispatchIndirectSubjectsForRelation( + ctx context.Context, + ci cursorInformation, req ValidatedLookupSubjectsRequest, stream dispatch.LookupSubjectsStream, - _ *core.Relation, reader datastore.Reader, ) error { - // TODO(jschorr): use type information to skip subject relations that cannot reach the subject type. + // TODO(jschorr): use reachability type information to skip subject relations that cannot reach the subject type. + // TODO(jschorr): Store the range of subjects found as a result of this call and store in the cursor to further optimize. + + // Lookup indirect subjects for redispatching. + // TODO: limit to only the necessary columns. See: https://github.com/authzed/spicedb/issues/1527 it, err := reader.QueryRelationships(ctx, datastore.RelationshipsFilter{ ResourceType: req.ResourceRelation.Namespace, OptionalResourceRelation: req.ResourceRelation.Relation, OptionalResourceIds: req.ResourceIds, + OptionalSubjectsSelectors: []datastore.SubjectsSelector{{ + RelationFilter: datastore.SubjectRelationFilter{}.WithOnlyNonEllipsisRelations(), + }}, }) if err != nil { return err @@ -111,45 +364,73 @@ func (cl *ConcurrentLookupSubjects) lookupDirectSubjects( defer it.Close() toDispatchByType := datasets.NewSubjectByTypeSet() - foundSubjectsByResourceID := datasets.NewSubjectSetByResourceID() relationshipsBySubjectONR := mapz.NewMultiMap[string, *core.RelationTuple]() for tpl := it.Next(); tpl != nil; tpl = it.Next() { if it.Err() != nil { return it.Err() } - if tpl.Subject.Namespace == req.SubjectRelation.Namespace && - tpl.Subject.Relation == req.SubjectRelation.Relation { - if err := foundSubjectsByResourceID.AddFromRelationship(tpl); err != nil { - return fmt.Errorf("failed to call AddFromRelationship in lookupDirectSubjects: %w", err) - } + err := toDispatchByType.AddSubjectOf(tpl) + if err != nil { + return err } - if tpl.Subject.Relation != tuple.Ellipsis { - err := toDispatchByType.AddSubjectOf(tpl) - if err != nil { - return err - } - - relationshipsBySubjectONR.Add(tuple.StringONR(tpl.Subject), tpl) - } + relationshipsBySubjectONR.Add(tuple.StringONR(tpl.Subject), tpl) } it.Close() - if !foundSubjectsByResourceID.IsEmpty() { - if err := stream.Publish(&v1.DispatchLookupSubjectsResponse{ - FoundSubjectsByResourceId: foundSubjectsByResourceID.AsMap(), - Metadata: emptyMetadata, - }); err != nil { - return err - } + return cl.dispatchTo(ctx, ci, req, toDispatchByType, relationshipsBySubjectONR, stream) +} + +// queryForDirectSubjects performs querying for direct subjects on the request's relation, with the specified +// subjects selector. The found subjects (if any) are added to the foundSubjectsByResourceID dataset. +func queryForDirectSubjects( + ctx context.Context, + req ValidatedLookupSubjectsRequest, + subjectsSelector datastore.SubjectsSelector, + afterCursor options.Cursor, + foundSubjectsByResourceID datasets.SubjectSetByResourceID, + reader datastore.Reader, + limit uint32, +) error { + queryOptions := []options.QueryOptionsOption{options.WithSort(options.BySubject), options.WithAfter(afterCursor)} + if limit > 0 { + limit64 := uint64(limit) + queryOptions = append(queryOptions, options.WithLimit(&limit64)) } - return cl.dispatchTo(ctx, req, toDispatchByType, relationshipsBySubjectONR, stream) + sit, err := reader.QueryRelationships(ctx, datastore.RelationshipsFilter{ + ResourceType: req.ResourceRelation.Namespace, + OptionalResourceRelation: req.ResourceRelation.Relation, + OptionalResourceIds: req.ResourceIds, + OptionalSubjectsSelectors: []datastore.SubjectsSelector{ + subjectsSelector, + }, + }, queryOptions...) + if err != nil { + return err + } + defer sit.Close() + + for tpl := sit.Next(); tpl != nil; tpl = sit.Next() { + if sit.Err() != nil { + return sit.Err() + } + if err := foundSubjectsByResourceID.AddFromRelationship(tpl); err != nil { + return fmt.Errorf("failed to call AddFromRelationship in lookupDirectSubjects: %w", err) + } + } + if sit.Err() != nil { + return sit.Err() + } + sit.Close() + return nil } +// lookupViaComputed redispatches LookupSubjects over a computed relation. func (cl *ConcurrentLookupSubjects) lookupViaComputed( ctx context.Context, + ci cursorInformation, parentRequest ValidatedLookupSubjectsRequest, parentStream dispatch.LookupSubjectsStream, cu *core.ComputedUserset, @@ -170,6 +451,7 @@ func (cl *ConcurrentLookupSubjects) lookupViaComputed( return &v1.DispatchLookupSubjectsResponse{ FoundSubjectsByResourceId: result.FoundSubjectsByResourceId, Metadata: addCallToResponseMetadata(result.Metadata), + AfterResponseCursor: result.AfterResponseCursor, }, true, nil }, } @@ -185,11 +467,15 @@ func (cl *ConcurrentLookupSubjects) lookupViaComputed( AtRevision: parentRequest.Revision.String(), DepthRemaining: parentRequest.Metadata.DepthRemaining - 1, }, + OptionalCursor: ci.currentCursor, + OptionalLimit: ci.limits.currentLimit, }, stream) } +// lookupViaTupleToUserset redispatches LookupSubjects over those objects found from an arrow (TTU). func (cl *ConcurrentLookupSubjects) lookupViaTupleToUserset( ctx context.Context, + ci cursorInformation, parentRequest ValidatedLookupSubjectsRequest, parentStream dispatch.LookupSubjectsStream, ttu *core.TupleToUserset, @@ -247,83 +533,159 @@ func (cl *ConcurrentLookupSubjects) lookupViaTupleToUserset( return err } - return cl.dispatchTo(ctx, parentRequest, toDispatchByComputedRelationType, relationshipsBySubjectONR, parentStream) + return cl.dispatchTo(ctx, ci, parentRequest, toDispatchByComputedRelationType, relationshipsBySubjectONR, parentStream) } +// lookupViaRewrite performs LookupSubjects over a rewrite operation (union, intersection, exclusion). func (cl *ConcurrentLookupSubjects) lookupViaRewrite( ctx context.Context, + ci cursorInformation, req ValidatedLookupSubjectsRequest, stream dispatch.LookupSubjectsStream, usr *core.UsersetRewrite, + concurrencyLimit uint16, ) error { switch rw := usr.RewriteOperation.(type) { case *core.UsersetRewrite_Union: log.Ctx(ctx).Trace().Msg("union") - return cl.lookupSetOperation(ctx, req, rw.Union, newLookupSubjectsUnion(stream)) + return cl.lookupSetOperationForUnion(ctx, ci, req, stream, rw.Union, concurrencyLimit) case *core.UsersetRewrite_Intersection: log.Ctx(ctx).Trace().Msg("intersection") - return cl.lookupSetOperation(ctx, req, rw.Intersection, newLookupSubjectsIntersection(stream)) + return cl.lookupSetOperationInSequence(ctx, ci, req, rw.Intersection, newLookupSubjectsIntersection(stream, ci), concurrencyLimit) case *core.UsersetRewrite_Exclusion: log.Ctx(ctx).Trace().Msg("exclusion") - return cl.lookupSetOperation(ctx, req, rw.Exclusion, newLookupSubjectsExclusion(stream)) + return cl.lookupSetOperationInSequence(ctx, ci, req, rw.Exclusion, newLookupSubjectsExclusion(stream, ci), concurrencyLimit) default: return fmt.Errorf("unknown kind of rewrite in lookup subjects") } } -func (cl *ConcurrentLookupSubjects) lookupSetOperation( +func (cl *ConcurrentLookupSubjects) lookupSetOperationForUnion( ctx context.Context, + ci cursorInformation, req ValidatedLookupSubjectsRequest, + stream dispatch.LookupSubjectsStream, so *core.SetOperation, - reducer lookupSubjectsReducer, + concurrencyLimit uint16, ) error { - cancelCtx, checkCancel := context.WithCancel(ctx) - defer checkCancel() - - g, subCtx := errgroup.WithContext(cancelCtx) - g.SetLimit(int(cl.concurrencyLimit)) - - for index, childOneof := range so.Child { - stream := reducer.ForIndex(subCtx, index) + // NOTE: unlike intersection or exclusion, union can run all of its branches in parallel, with the starting cursor + // and limit, as the results will be merged at completion of the operation and any "extra" results will be tossed. + reducer := newLookupSubjectsUnion(stream, ci) + runChild := func(cctx context.Context, cstream dispatch.LookupSubjectsStream, childOneof *core.SetOperation_Child) error { switch child := childOneof.ChildType.(type) { case *core.SetOperation_Child_XThis: return errors.New("use of _this is unsupported; please rewrite your schema") case *core.SetOperation_Child_ComputedUserset: - g.Go(func() error { - return cl.lookupViaComputed(subCtx, req, stream, child.ComputedUserset) - }) + return cl.lookupViaComputed(cctx, ci, req, cstream, child.ComputedUserset) case *core.SetOperation_Child_UsersetRewrite: - g.Go(func() error { - return cl.lookupViaRewrite(subCtx, req, stream, child.UsersetRewrite) - }) + return cl.lookupViaRewrite(cctx, ci, req, cstream, child.UsersetRewrite, adjustConcurrencyLimit(concurrencyLimit, len(so.Child))) case *core.SetOperation_Child_TupleToUserset: - g.Go(func() error { - return cl.lookupViaTupleToUserset(subCtx, req, stream, child.TupleToUserset) - }) + return cl.lookupViaTupleToUserset(cctx, ci, req, cstream, child.TupleToUserset) case *core.SetOperation_Child_XNil: // Purposely do nothing. - continue + return nil default: return fmt.Errorf("unknown set operation child `%T` in expand", child) } } - // Wait for all dispatched operations to complete. - if err := g.Wait(); err != nil { - return err + // Skip the goroutines when there is a single child, such as a direct aliasing of a permission (permission foo = bar) + if len(so.Child) == 1 { + if err := runChild(ctx, reducer.ForIndex(ctx, 0), so.Child[0]); err != nil { + return err + } + } else { + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + + g, subCtx := errgroup.WithContext(cancelCtx) + g.SetLimit(int(concurrencyLimit)) + + for index, childOneof := range so.Child { + stream := reducer.ForIndex(subCtx, index) + childOneof := childOneof + g.Go(func() error { + return runChild(subCtx, stream, childOneof) + }) + } + + // Wait for all dispatched operations to complete. + if err := g.Wait(); err != nil { + return err + } } return reducer.CompletedChildOperations() } +func (cl *ConcurrentLookupSubjects) lookupSetOperationInSequence( + ctx context.Context, + ci cursorInformation, + req ValidatedLookupSubjectsRequest, + so *core.SetOperation, + reducer *dependentBranchReducer, + concurrencyLimit uint16, +) error { + // Run the intersection/exclusion until the limit is reached (if applicable) or until results are exhausted. + for { + if ci.limits.hasExhaustedLimit() { + return nil + } + + // In order to run a cursored/limited intersection or exclusion, we need to ensure that the later branches represent + // the entire span of results from the first branch. Therefore, we run the first branch, gets its results, then run + // the later branches, looping until the entire span is computed. The span looping occurs within RunUntilSpanned based + // on the passed in `index`. + for index, childOneof := range so.Child { + stream := reducer.ForIndex(ctx, index) + err := reducer.RunUntilSpanned(ctx, index, func(ctx context.Context, current branchRunInformation) error { + switch child := childOneof.ChildType.(type) { + case *core.SetOperation_Child_XThis: + return errors.New("use of _this is unsupported; please rewrite your schema") + + case *core.SetOperation_Child_ComputedUserset: + return cl.lookupViaComputed(ctx, current.ci, req, stream, child.ComputedUserset) + + case *core.SetOperation_Child_UsersetRewrite: + return cl.lookupViaRewrite(ctx, current.ci, req, stream, child.UsersetRewrite, concurrencyLimit) + + case *core.SetOperation_Child_TupleToUserset: + return cl.lookupViaTupleToUserset(ctx, current.ci, req, stream, child.TupleToUserset) + + case *core.SetOperation_Child_XNil: + // Purposely do nothing. + return nil + + default: + return fmt.Errorf("unknown set operation child `%T` in expand", child) + } + }) + if err != nil { + return err + } + } + + firstBranchConcreteCount, err := reducer.CompletedDependentChildOperations() + if err != nil { + return err + } + + // If the first branch has no additional results, then we're done. + if firstBranchConcreteCount == 0 { + return nil + } + } +} + func (cl *ConcurrentLookupSubjects) dispatchTo( ctx context.Context, + ci cursorInformation, parentRequest ValidatedLookupSubjectsRequest, toDispatchByType *datasets.SubjectByTypeSet, relationshipsBySubjectONR *mapz.MultiMap[string, *core.RelationTuple], @@ -333,13 +695,11 @@ func (cl *ConcurrentLookupSubjects) dispatchTo( return nil } - cancelCtx, checkCancel := context.WithCancel(ctx) - defer checkCancel() - - g, subCtx := errgroup.WithContext(cancelCtx) - g.SetLimit(int(cl.concurrencyLimit)) + return toDispatchByType.ForEachTypeUntil(func(resourceType *core.RelationReference, foundSubjects datasets.SubjectSet) (bool, error) { + if ci.limits.hasExhaustedLimit() { + return false, nil + } - toDispatchByType.ForEachType(func(resourceType *core.RelationReference, foundSubjects datasets.SubjectSet) { slice := foundSubjects.AsSlice() resourceIds := make([]string, 0, len(slice)) for _, foundSubject := range slice { @@ -348,7 +708,7 @@ func (cl *ConcurrentLookupSubjects) dispatchTo( stream := &dispatch.WrappedDispatchStream[*v1.DispatchLookupSubjectsResponse]{ Stream: parentStream, - Ctx: subCtx, + Ctx: ctx, Processor: func(result *v1.DispatchLookupSubjectsResponse) (*v1.DispatchLookupSubjectsResponse, bool, error) { // For any found subjects, map them through their associated starting resources, to apply any caveats that were // only those resources' relationships. @@ -364,7 +724,7 @@ func (cl *ConcurrentLookupSubjects) dispatchTo( // This will produce: // - firstdoc => {user:tom, user:sarah, user:fred[somecaveat]} // - mappedFoundSubjects := make(map[string]*v1.FoundSubjects) + mappedFoundSubjects := make(map[string]*v1.FoundSubjects, len(result.FoundSubjectsByResourceId)) for childResourceID, foundSubjects := range result.FoundSubjectsByResourceId { subjectKey := tuple.StringONR(&core.ObjectAndRelation{ Namespace: resourceType.Namespace, @@ -409,30 +769,87 @@ func (cl *ConcurrentLookupSubjects) dispatchTo( } } + // NOTE: this response does not need to be limited or filtered because the child dispatch has already done so. return &v1.DispatchLookupSubjectsResponse{ FoundSubjectsByResourceId: mappedFoundSubjects, Metadata: addCallToResponseMetadata(result.Metadata), + AfterResponseCursor: result.AfterResponseCursor, }, true, nil }, } // Dispatch the found subjects as the resources of the next step. - slicez.ForEachChunk(resourceIds, maxDispatchChunkSize, func(resourceIdChunk []string) { - g.Go(func() error { - return cl.d.DispatchLookupSubjects(&v1.DispatchLookupSubjectsRequest{ - ResourceRelation: resourceType, - ResourceIds: resourceIdChunk, - SubjectRelation: parentRequest.SubjectRelation, - Metadata: &v1.ResolverMeta{ - AtRevision: parentRequest.Revision.String(), - DepthRemaining: parentRequest.Metadata.DepthRemaining - 1, - }, - }, stream) - }) + return slicez.ForEachChunkUntil(resourceIds, maxDispatchChunkSize, func(resourceIdChunk []string) (bool, error) { + err := cl.d.DispatchLookupSubjects(&v1.DispatchLookupSubjectsRequest{ + ResourceRelation: resourceType, + ResourceIds: resourceIdChunk, + SubjectRelation: parentRequest.SubjectRelation, + Metadata: &v1.ResolverMeta{ + AtRevision: parentRequest.Revision.String(), + DepthRemaining: parentRequest.Metadata.DepthRemaining - 1, + }, + OptionalCursor: ci.currentCursor, + OptionalLimit: ci.limits.currentLimit, + }, stream) + if err != nil { + return false, err + } + + return true, nil }) }) +} + +type unionOperation struct { + callback func(ctx context.Context, stream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error + runIf bool +} + +// runInParallel runs the given operations in parallel, union-ing together the results from the operations. +func runInParallel(ctx context.Context, ci cursorInformation, stream dispatch.LookupSubjectsStream, concurrencyLimit uint16, operations ...unionOperation) error { + filteredOperations := make([]unionOperation, 0, len(operations)) + for _, op := range operations { + if op.runIf { + filteredOperations = append(filteredOperations, op) + } + } + + // If there is no work to be done, return. + if len(filteredOperations) == 0 { + return nil + } + + // If there is only a single operation to run, just invoke it directly to avoid creating unnecessary goroutines and + // additional work. + if len(filteredOperations) == 1 { + return filteredOperations[0].callback(ctx, stream, concurrencyLimit) + } + + // Otherwise, run each operation in parallel and union together the results via a reducer. + reducer := newLookupSubjectsUnion(stream, ci) + + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + + g, subCtx := errgroup.WithContext(cancelCtx) + g.SetLimit(int(concurrencyLimit)) + + adjustedLimit := adjustConcurrencyLimit(concurrencyLimit, 1) + for index, fop := range filteredOperations { + opStream := reducer.ForIndex(subCtx, index) + fop := fop + adjustedLimit = adjustedLimit - 1 + currentLimit := adjustedLimit + g.Go(func() error { + return fop.callback(subCtx, opStream, currentLimit) + }) + } - return g.Wait() + if err := g.Wait(); err != nil { + return err + } + + return reducer.CompletedChildOperations() } func combineFoundSubjects(existing *v1.FoundSubjects, toAdd *v1.FoundSubjects) (*v1.FoundSubjects, error) { @@ -449,21 +866,19 @@ func combineFoundSubjects(existing *v1.FoundSubjects, toAdd *v1.FoundSubjects) ( }, nil } -type lookupSubjectsReducer interface { - ForIndex(ctx context.Context, setOperationIndex int) dispatch.LookupSubjectsStream - CompletedChildOperations() error -} - -// Union +// lookupSubjectsUnion defines a reducer for union operations, where all the results from each stream +// for each branch are unioned together, filtered, limited and then published. type lookupSubjectsUnion struct { parentStream dispatch.LookupSubjectsStream collectors map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse] + ci cursorInformation } -func newLookupSubjectsUnion(parentStream dispatch.LookupSubjectsStream) *lookupSubjectsUnion { +func newLookupSubjectsUnion(parentStream dispatch.LookupSubjectsStream, ci cursorInformation) *lookupSubjectsUnion { return &lookupSubjectsUnion{ parentStream: parentStream, collectors: map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse]{}, + ci: ci, } } @@ -495,114 +910,389 @@ func (lsu *lookupSubjectsUnion) CompletedChildOperations() error { return nil } - return lsu.parentStream.Publish(&v1.DispatchLookupSubjectsResponse{ - FoundSubjectsByResourceId: foundSubjects.AsMap(), - Metadata: metadata, - }) + // Since we've collected results from multiple branches, some which may be past the end of the overall limit, + // do a cursor-based filtering here to enure we only return the limit. + resp, done, err := createFilteredAndLimitedResponse(lsu.ci, foundSubjects.AsMap(), metadata) + defer done() + if err != nil { + return err + } + + if resp == nil { + return nil + } + + return lsu.parentStream.Publish(resp) } -// Intersection -type lookupSubjectsIntersection struct { - parentStream dispatch.LookupSubjectsStream - collectors map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse] +// branchRunInformation is information passed to a RunUntilSpanned handler. +type branchRunInformation struct { + ci cursorInformation } -func newLookupSubjectsIntersection(parentStream dispatch.LookupSubjectsStream) *lookupSubjectsIntersection { - return &lookupSubjectsIntersection{ - parentStream: parentStream, - collectors: map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse]{}, - } +// dependentBranchReducerReloopLimit is the limit of results for each iteration of the dependent branch LookupSubject redispatches. +const dependentBranchReducerReloopLimit = 1000 + +// dependentBranchReducer is the implementation reducer for any rewrite operations whose branches depend upon one another +// (intersection and exclusion). +type dependentBranchReducer struct { + // parentStream is the stream to which results will be published, after reduction. + parentStream dispatch.LookupSubjectsStream + + // collectors are a map from branch index to the associated collector of stream results. + collectors map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse] + + // parentCi is the cursor information from the parent call. + parentCi cursorInformation + + // combinationHandler is the function invoked to "combine" the results from different branches, such as performing + // intersection or exclusion. + combinationHandler func(fs datasets.SubjectSetByResourceID, other datasets.SubjectSetByResourceID) error + + // firstBranchCi is the *current* cursor for the first branch; this value is updated during iteration as the reducer is + // re-run. + firstBranchCi cursorInformation } -func (lsi *lookupSubjectsIntersection) ForIndex(ctx context.Context, setOperationIndex int) dispatch.LookupSubjectsStream { +// ForIndex returns the stream to which results should be published for the branch with the given index. Must not be called +// in parallel. +func (dbr *dependentBranchReducer) ForIndex(ctx context.Context, setOperationIndex int) dispatch.LookupSubjectsStream { collector := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupSubjectsResponse](ctx) - lsi.collectors[setOperationIndex] = collector + dbr.collectors[setOperationIndex] = collector return collector } -func (lsi *lookupSubjectsIntersection) CompletedChildOperations() error { +// RunUntilSpanned runs the branch (with the given index) until all necessary results have been collected. For the first branch, +// this is just a direct invocation. For all other branches, the handler will be reinvoked until all results have been collected +// *or* the last subject ID found is >= the last subject ID found by the first branch, ensuring that all other branches have +// "spanned" the subjects of the first branch. This is necessary because an intersection or exclusion must operate over the same +// set of subject IDs. +func (dbr *dependentBranchReducer) RunUntilSpanned(ctx context.Context, index int, handler func(ctx context.Context, current branchRunInformation) error) error { + // If invoking the run for the first branch, use the current first branch cursor. + if index == 0 { + return handler(ctx, branchRunInformation{ci: dbr.firstBranchCi.withClonedLimits()}) + } + + // Otherwise, run the branch until it has either exhausted all results OR the last result returned matches the last result previously + // returned by the first branch. This is to ensure that the other branches encompass the entire "span" of results from the first branch, + // which is necessary for intersection or exclusion (e.g. dependent branches). + firstBranchTerminalSubjectID, err := finalSubjectIDForResults(dbr.firstBranchCi, dbr.collectors[0].Results()) + if err != nil { + return err + } + + // If there are no concrete subject IDs found, then simply invoke the handler with the first branch's cursor/limit to + // return the wildcard; all other results will be superflouous. + if firstBranchTerminalSubjectID == "" { + return handler(ctx, branchRunInformation{ci: dbr.firstBranchCi}) + } + + // Otherwise, run the handler until its returned results is empty OR its cursor is >= the terminal subject ID. + startingCursor := dbr.firstBranchCi.currentCursor + previousResultCount := 0 + for { + limits := newLimitTracker(dependentBranchReducerReloopLimit) + ci, err := newCursorInformation(startingCursor, limits, lsDispatchVersion) + if err != nil { + return err + } + + // Invoke the handler with a modified limits and a cursor starting at the previous call. + if err := handler(ctx, branchRunInformation{ + ci: ci, + }); err != nil { + return err + } + + // Check for any new results found. If none, then we're done. + updatedResults := dbr.collectors[index].Results() + if len(updatedResults) == previousResultCount { + return nil + } + + // Otherwise, grab the terminal subject ID to create the next cursor. + previousResultCount = len(updatedResults) + terminalSubjectID, err := finalSubjectIDForResults(dbr.parentCi, updatedResults) + if err != nil { + return nil + } + + // If the cursor is now the wildcard, then we know that all concrete results have been consumed. + if terminalSubjectID == tuple.PublicWildcard { + return nil + } + + // If the terminal subject in the results collector is now at or beyond that of the first branch, then + // we've spanned the entire results set necessary to perform the intersection or exclusion. + if firstBranchTerminalSubjectID != tuple.PublicWildcard && terminalSubjectID >= firstBranchTerminalSubjectID { + return nil + } + + startingCursor = updatedResults[len(updatedResults)-1].AfterResponseCursor + } +} + +// CompletedDependentChildOperations is invoked once all branches have been run to perform combination and publish any +// valid subject IDs. This also moves the first branch's cursor forward. +// +// Returns the number of results from the first branch, and/or any error. The number of results is used to determine whether +// the first branch has been exhausted. +func (dbr *dependentBranchReducer) CompletedDependentChildOperations() (int, error) { + firstBranchCount := -1 + + // Update the first branch cursor for moving forward. This ensures that each iteration of the first branch for + // RunUntilSpanned is moving forward. + firstBranchTerminalSubjectID, err := finalSubjectIDForResults(dbr.parentCi, dbr.collectors[0].Results()) + if err != nil { + return firstBranchCount, err + } + + existingFirstBranchCI := dbr.firstBranchCi + if firstBranchTerminalSubjectID != "" { + updatedCI, err := dbr.firstBranchCi.withOutgoingSection(firstBranchTerminalSubjectID) + if err != nil { + return -1, err + } + + updatedCursor := updatedCI.responsePartialCursor() + fbci, err := newCursorInformation(updatedCursor, dbr.firstBranchCi.limits, lsDispatchVersion) + if err != nil { + return firstBranchCount, err + } + + dbr.firstBranchCi = fbci + } + + // Run the combiner over the results. var foundSubjects datasets.SubjectSetByResourceID metadata := emptyMetadata - for index := 0; index < len(lsi.collectors); index++ { - collector, ok := lsi.collectors[index] + for index := 0; index < len(dbr.collectors); index++ { + collector, ok := dbr.collectors[index] if !ok { - return fmt.Errorf("missing collector for index %d", index) + return firstBranchCount, fmt.Errorf("missing collector for index %d", index) } results := datasets.NewSubjectSetByResourceID() for _, result := range collector.Results() { metadata = combineResponseMetadata(metadata, result.Metadata) if err := results.UnionWith(result.FoundSubjectsByResourceId); err != nil { - return fmt.Errorf("failed to UnionWith under lookupSubjectsIntersection: %w", err) + return firstBranchCount, fmt.Errorf("failed to UnionWith: %w", err) } } if index == 0 { foundSubjects = results + firstBranchCount = results.ConcreteSubjectCount() } else { - err := foundSubjects.IntersectionDifference(results) + err := dbr.combinationHandler(foundSubjects, results) if err != nil { - return err + return firstBranchCount, err } if foundSubjects.IsEmpty() { - return nil + return firstBranchCount, nil } } } - return lsi.parentStream.Publish(&v1.DispatchLookupSubjectsResponse{ - FoundSubjectsByResourceId: foundSubjects.AsMap(), - Metadata: metadata, - }) + // Apply the limits to the found results. + resp, done, err := createFilteredAndLimitedResponse(existingFirstBranchCI, foundSubjects.AsMap(), metadata) + defer done() + if err != nil { + return firstBranchCount, err + } + + if resp == nil { + return firstBranchCount, nil + } + + return firstBranchCount, dbr.parentStream.Publish(resp) } -// Exclusion -type lookupSubjectsExclusion struct { - parentStream dispatch.LookupSubjectsStream - collectors map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse] +func newLookupSubjectsIntersection(parentStream dispatch.LookupSubjectsStream, ci cursorInformation) *dependentBranchReducer { + return &dependentBranchReducer{ + parentStream: parentStream, + collectors: map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse]{}, + parentCi: ci, + combinationHandler: func(fs datasets.SubjectSetByResourceID, other datasets.SubjectSetByResourceID) error { + return fs.IntersectionDifference(other) + }, + firstBranchCi: ci, + } } -func newLookupSubjectsExclusion(parentStream dispatch.LookupSubjectsStream) *lookupSubjectsExclusion { - return &lookupSubjectsExclusion{ +func newLookupSubjectsExclusion(parentStream dispatch.LookupSubjectsStream, ci cursorInformation) *dependentBranchReducer { + return &dependentBranchReducer{ parentStream: parentStream, collectors: map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse]{}, + parentCi: ci, + combinationHandler: func(fs datasets.SubjectSetByResourceID, other datasets.SubjectSetByResourceID) error { + fs.SubtractAll(other) + return nil + }, + firstBranchCi: ci, } } -func (lse *lookupSubjectsExclusion) ForIndex(ctx context.Context, setOperationIndex int) dispatch.LookupSubjectsStream { - collector := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupSubjectsResponse](ctx) - lse.collectors[setOperationIndex] = collector - return collector +// finalSubjectIDForResults returns the ID of the last subject (sorted) in the results, if any. +// Returns empty string if none. +func finalSubjectIDForResults(ci cursorInformation, results []*v1.DispatchLookupSubjectsResponse) (string, error) { + endingSubjectIDs := mapz.NewSet[string]() + for _, result := range results { + frc, err := newCursorInformation(result.AfterResponseCursor, ci.limits, lsDispatchVersion) + if err != nil { + return "", err + } + + lastSubjectID, _ := frc.headSectionValue() + if lastSubjectID == "" { + return "", spiceerrors.MustBugf("got invalid cursor") + } + + endingSubjectIDs.Add(lastSubjectID) + } + + sortedSubjectIDs := endingSubjectIDs.AsSlice() + sort.Strings(sortedSubjectIDs) + + if len(sortedSubjectIDs) == 0 { + return "", nil + } + + return sortedSubjectIDs[len(sortedSubjectIDs)-1], nil } -func (lse *lookupSubjectsExclusion) CompletedChildOperations() error { - var foundSubjects datasets.SubjectSetByResourceID - metadata := emptyMetadata +// createFilteredAndLimitedResponse creates a filtered and limited (as is necessary via the cursor and limits) +// version of the subjects, returning a DispatchLookupSubjectsResponse ready for publishing with just that +// subset of results. +func createFilteredAndLimitedResponse( + ci cursorInformation, + subjects map[string]*v1.FoundSubjects, + metadata *v1.ResponseMeta, +) (*v1.DispatchLookupSubjectsResponse, func(), error) { + if subjects == nil { + return nil, func() {}, spiceerrors.MustBugf("nil subjects given to createFilteredAndLimitedResponse") + } - for index := 0; index < len(lse.collectors); index++ { - collector := lse.collectors[index] - results := datasets.NewSubjectSetByResourceID() - for _, result := range collector.Results() { - metadata = combineResponseMetadata(metadata, result.Metadata) - if err := results.UnionWith(result.FoundSubjectsByResourceId); err != nil { - return fmt.Errorf("failed to UnionWith under lookupSubjectsExclusion: %w", err) + afterSubjectID, _ := ci.headSectionValue() + + // Filter down the subjects found by the cursor (if applicable) and then apply a limit. + filteredSubjectIDs := mapz.NewSet[string]() + for _, foundSubjects := range subjects { + for _, foundSubject := range foundSubjects.FoundSubjects { + // NOTE: wildcard is always returned, because it is needed by all branches, at all times. + if foundSubject.SubjectId == tuple.PublicWildcard || (afterSubjectID == "" || foundSubject.SubjectId > afterSubjectID) { + filteredSubjectIDs.Add(foundSubject.SubjectId) } } + } - if index == 0 { - foundSubjects = results - } else { - foundSubjects.SubtractAll(results) - if foundSubjects.IsEmpty() { - return nil - } + sortedSubjectIDs := filteredSubjectIDs.AsSlice() + sort.Strings(sortedSubjectIDs) + + subjectIDsToPublish := make([]string, 0, len(sortedSubjectIDs)) + lastSubjectIDToPublishWithoutWildcard := "" + + done := func() {} + for _, subjectID := range sortedSubjectIDs { + // Wildcards are always published, regardless of the limit. + if subjectID == tuple.PublicWildcard { + subjectIDsToPublish = append(subjectIDsToPublish, subjectID) + continue } + + ok := ci.limits.prepareForPublishing() + if !ok { + break + } + + subjectIDsToPublish = append(subjectIDsToPublish, subjectID) + lastSubjectIDToPublishWithoutWildcard = subjectID } - return lse.parentStream.Publish(&v1.DispatchLookupSubjectsResponse{ - FoundSubjectsByResourceId: foundSubjects.AsMap(), + if len(subjectIDsToPublish) == 0 { + return nil, done, nil + } + + // Determine the subject ID for the cursor. If there are any concrete subject IDs, then the last + // one is used. Otherwise, the wildcard itself is published as a specialized cursor to indicate that + // all concrete subjects have been consumed. + cursorSubjectID := "*" + if len(lastSubjectIDToPublishWithoutWildcard) > 0 { + cursorSubjectID = lastSubjectIDToPublishWithoutWildcard + } + + updatedCI, err := ci.withOutgoingSection(cursorSubjectID) + if err != nil { + return nil, done, err + } + + return &v1.DispatchLookupSubjectsResponse{ + FoundSubjectsByResourceId: filterSubjectsMap(subjects, subjectIDsToPublish), Metadata: metadata, - }) + AfterResponseCursor: updatedCI.responsePartialCursor(), + }, done, nil +} + +// publishSubjects publishes the given subjects to the stream, after appying filtering and limiting. +func publishSubjects(stream dispatch.LookupSubjectsStream, ci cursorInformation, subjects map[string]*v1.FoundSubjects) error { + response, done, err := createFilteredAndLimitedResponse(ci, subjects, emptyMetadata) + defer done() + if err != nil { + return err + } + + if response == nil { + return nil + } + + return stream.Publish(response) +} + +// filterSubjectsMap filters the subjects found in the subjects map to only those allowed, returning an updated map. +func filterSubjectsMap(subjects map[string]*v1.FoundSubjects, allowedSubjectIds []string) map[string]*v1.FoundSubjects { + updated := make(map[string]*v1.FoundSubjects, len(subjects)) + allowed := mapz.NewSet[string](allowedSubjectIds...) + + for key, subjects := range subjects { + filtered := make([]*v1.FoundSubject, 0, len(subjects.FoundSubjects)) + + for _, subject := range subjects.FoundSubjects { + if !allowed.Has(subject.SubjectId) { + continue + } + + filtered = append(filtered, subject) + } + + sort.Sort(bySubjectID(filtered)) + if len(filtered) > 0 { + updated[key] = &v1.FoundSubjects{FoundSubjects: filtered} + } + } + + return updated +} + +func adjustConcurrencyLimit(concurrencyLimit uint16, count int) uint16 { + if int(concurrencyLimit)-count <= 0 { + return 1 + } + + return concurrencyLimit - uint16(count) +} + +type bySubjectID []*v1.FoundSubject + +func (u bySubjectID) Len() int { + return len(u) +} + +func (u bySubjectID) Swap(i, j int) { + u[i], u[j] = u[j], u[i] +} + +func (u bySubjectID) Less(i, j int) bool { + return u[i].SubjectId < u[j].SubjectId } diff --git a/internal/graph/lookupsubjects_test.go b/internal/graph/lookupsubjects_test.go new file mode 100644 index 0000000000..9d1ae54b03 --- /dev/null +++ b/internal/graph/lookupsubjects_test.go @@ -0,0 +1,214 @@ +package graph + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + + v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" +) + +func fsubs(subjectIDs ...string) *v1.FoundSubjects { + subs := make([]*v1.FoundSubject, 0, len(subjectIDs)) + for _, subjectID := range subjectIDs { + subs = append(subs, fs(subjectID)) + } + return &v1.FoundSubjects{ + FoundSubjects: subs, + } +} + +func fs(subjectID string) *v1.FoundSubject { + return &v1.FoundSubject{ + SubjectId: subjectID, + } +} + +func TestCreateFilteredAndLimitedResponse(t *testing.T) { + tcs := []struct { + name string + subjectIDCursor string + input map[string]*v1.FoundSubjects + limit uint32 + expected map[string]*v1.FoundSubjects + }{ + { + "basic limit, no filtering", + "", + map[string]*v1.FoundSubjects{ + "foo": fsubs("a", "b", "c"), + "bar": fsubs("a", "b", "d"), + }, + 3, + map[string]*v1.FoundSubjects{ + "foo": fsubs("a", "b", "c"), + "bar": fsubs("a", "b"), + }, + }, + { + "basic limit removes key", + "", + map[string]*v1.FoundSubjects{ + "foo": fsubs("a", "b", "c"), + "bar": fsubs("b", "d"), + }, + 1, + map[string]*v1.FoundSubjects{ + "foo": fsubs("a"), + }, + }, + { + "limit maintains wildcard", + "", + map[string]*v1.FoundSubjects{ + "foo": fsubs("a", "b", "c"), + "bar": fsubs("b", "d", "*"), + }, + 1, + map[string]*v1.FoundSubjects{ + "foo": fsubs("a"), + "bar": fsubs("*"), + }, + }, + { + "basic limit, with filtering", + "a", + map[string]*v1.FoundSubjects{ + "foo": fsubs("a", "b", "c"), + "bar": fsubs("a", "b", "d"), + }, + 2, + map[string]*v1.FoundSubjects{ + "foo": fsubs("b", "c"), + "bar": fsubs("b"), + }, + }, + { + "basic limit, with filtering includes both", + "a", + map[string]*v1.FoundSubjects{ + "foo": fsubs("a", "b", "c"), + "bar": fsubs("a", "b", "d"), + }, + 3, + map[string]*v1.FoundSubjects{ + "foo": fsubs("b", "c"), + "bar": fsubs("b", "d"), + }, + }, + { + "filtered limit maintains wildcard", + "z", + map[string]*v1.FoundSubjects{ + "foo": fsubs("a", "b", "*", "c"), + "bar": fsubs("b", "d", "*"), + }, + 10, + map[string]*v1.FoundSubjects{ + "foo": fsubs("*"), + "bar": fsubs("*"), + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + limits := newLimitTracker(tc.limit) + + var cursor *v1.Cursor + if tc.subjectIDCursor != "" { + cursor = &v1.Cursor{ + DispatchVersion: lsDispatchVersion, + Sections: []string{tc.subjectIDCursor}, + } + } + + ci, err := newCursorInformation(cursor, limits, lsDispatchVersion) + require.NoError(t, err) + + resp, _, err := createFilteredAndLimitedResponse(ci, tc.input, emptyMetadata) + require.NoError(t, err) + require.Equal(t, tc.expected, resp.FoundSubjectsByResourceId) + }) + } +} + +func TestFilterSubjectsMap(t *testing.T) { + tcs := []struct { + name string + input map[string]*v1.FoundSubjects + allowedSubjectIds []string + expected map[string]*v1.FoundSubjects + }{ + { + "filter to empty", + map[string]*v1.FoundSubjects{ + "foo": fsubs("first"), + }, + nil, + map[string]*v1.FoundSubjects{}, + }, + { + "filter and remove key", + map[string]*v1.FoundSubjects{ + "foo": fsubs("first", "second", "third"), + "bar": fsubs("first", "second", "fourth"), + }, + []string{"third"}, + map[string]*v1.FoundSubjects{ + "foo": fsubs("third"), + }, + }, + { + "filter multiple keys", + map[string]*v1.FoundSubjects{ + "foo": fsubs("first", "second", "third"), + "bar": fsubs("first", "second", "fourth"), + }, + []string{"first"}, + map[string]*v1.FoundSubjects{ + "foo": fsubs("first"), + "bar": fsubs("first"), + }, + }, + { + "filter multiple keys with multiple values", + map[string]*v1.FoundSubjects{ + "foo": fsubs("first", "second", "third"), + "bar": fsubs("first", "second", "fourth"), + }, + []string{"first", "second"}, + map[string]*v1.FoundSubjects{ + "foo": fsubs("first", "second"), + "bar": fsubs("first", "second"), + }, + }, + { + "filter remove key with multiple values", + map[string]*v1.FoundSubjects{ + "foo": fsubs("first", "second", "third"), + "bar": fsubs("first", "second", "fourth"), + }, + []string{"third", "fourth"}, + map[string]*v1.FoundSubjects{ + "foo": fsubs("third"), + "bar": fsubs("fourth"), + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + filtered := filterSubjectsMap(tc.input, tc.allowedSubjectIds) + require.Equal(t, tc.expected, filtered) + + for _, values := range filtered { + sorted := slices.Clone(values.FoundSubjects) + sort.Sort(bySubjectID(sorted)) + require.Equal(t, sorted, values.FoundSubjects, "found unsorted subjects: %v", values.FoundSubjects) + } + }) + } +} diff --git a/internal/graph/reachableresources.go b/internal/graph/reachableresources.go index 6d644b05f6..37d98d7eb2 100644 --- a/internal/graph/reachableresources.go +++ b/internal/graph/reachableresources.go @@ -17,10 +17,10 @@ import ( "github.com/authzed/spicedb/pkg/typesystem" ) -// dispatchVersion defines the "version" of this dispatcher. Must be incremented +// rrDispatchVersion defines the "version" of this dispatcher. Must be incremented // anytime an incompatible change is made to the dispatcher itself or its cursor // production. -const dispatchVersion = 1 +const rrDispatchVersion = 1 // NewCursoredReachableResources creates an instance of CursoredReachableResources. func NewCursoredReachableResources(d dispatch.ReachableResources, concurrencyLimit uint16) *CursoredReachableResources { @@ -54,7 +54,7 @@ func (crr *CursoredReachableResources) ReachableResources( ctx := stream.Context() limits := newLimitTracker(req.OptionalLimit) - ci, err := newCursorInformation(req.OptionalCursor, limits, dispatchVersion) + ci, err := newCursorInformation(req.OptionalCursor, limits, rrDispatchVersion) if err != nil { return err } diff --git a/internal/services/integrationtesting/consistency_test.go b/internal/services/integrationtesting/consistency_test.go index cd9d8a8b9e..fb363fe31d 100644 --- a/internal/services/integrationtesting/consistency_test.go +++ b/internal/services/integrationtesting/consistency_test.go @@ -181,6 +181,7 @@ func testForEachResource( ) { t.Helper() + encountered := mapz.NewSet[string]() for _, resourceType := range vctx.clusterAndData.Populated.NamespaceDefinitions { resources, ok := vctx.accessibilitySet.ResourcesByNamespace.Get(resourceType.Name) if !ok { @@ -192,13 +193,19 @@ func testForEachResource( relation := relation for _, resource := range resources { resource := resource + onr := &core.ObjectAndRelation{ + Namespace: resourceType.Name, + ObjectId: resource.ObjectId, + Relation: relation.Name, + } + key := tuple.StringONR(onr) + if !encountered.Add(key) { + continue + } + t.Run(fmt.Sprintf("%s_%s_%s_%s", prefix, resourceType.Name, resource.ObjectId, relation.Name), func(t *testing.T) { - handler(t, &core.ObjectAndRelation{ - Namespace: resourceType.Name, - ObjectId: resource.ObjectId, - Relation: relation.Name, - }) + handler(t, onr) }) } } @@ -368,7 +375,7 @@ func validateLookupResources(t *testing.T, vctx validationContext) { require.NoError(t, err) if pageSize > 0 { - require.LessOrEqual(t, len(foundResources), int(pageSize)) + require.LessOrEqual(t, len(foundResources), int(pageSize)+1) // +1 for the wildcard } currentCursor = lastCursor @@ -460,152 +467,176 @@ func validateLookupSubjects(t *testing.T, vctx validationContext) { subjectType := subjectType t.Run(fmt.Sprintf("%s#%s", subjectType.Namespace, subjectType.Relation), func(t *testing.T) { - resolvedSubjects, err := vctx.serviceTester.LookupSubjects(context.Background(), resource, subjectType, vctx.revision, nil) - require.NoError(t, err) - - // Ensure the subjects found include those defined as expected. Since the - // accessibility set does not include "inferred" subjects (e.g. those with - // permissions as their subject relation, or wildcards), this should be a - // subset. - expectedDefinedSubjects := vctx.accessibilitySet.DirectlyAccessibleDefinedSubjectsOfType(resource, subjectType) - requireSubsetOf(t, maps.Keys(resolvedSubjects), maps.Keys(expectedDefinedSubjects)) - - // Ensure all subjects in true and caveated assertions for the subject type are found - // in the LookupSubject result, except those added via wildcard. - for _, parsedFile := range vctx.clusterAndData.Populated.ParsedFiles { - for _, entry := range []struct { - assertions []blocks.Assertion - requiresPermission bool - }{ - { - assertions: parsedFile.Assertions.AssertTrue, - requiresPermission: true, - }, - { - assertions: parsedFile.Assertions.AssertCaveated, - requiresPermission: false, - }, - } { - for _, assertion := range entry.assertions { - assertionRel := tuple.MustFromRelationship[*v1.ObjectReference, *v1.SubjectReference, *v1.ContextualizedCaveat](assertion.Relationship) - if !assertionRel.ResourceAndRelation.EqualVT(resource) { - continue + for _, pageSize := range []uint32{0, 2} { + pageSize := pageSize + t.Run(fmt.Sprintf("pagesize-%d", pageSize), func(t *testing.T) { + // Loop until all subjects have been found or we've hit max iterations. + var currentCursor *v1.Cursor + resolvedSubjects := map[string]*v1.LookupSubjectsResponse{} + for i := 0; i < 100; i++ { + foundSubjects, lastCursor, err := vctx.serviceTester.LookupSubjects(context.Background(), resource, subjectType, vctx.revision, nil, currentCursor, pageSize) + require.NoError(t, err) + + if pageSize > 0 { + require.LessOrEqual(t, len(foundSubjects), int(pageSize)+1) // +1 for possible wildcard } - if assertionRel.Subject.Namespace != subjectType.Namespace || - assertionRel.Subject.Relation != subjectType.Relation { - continue + currentCursor = lastCursor + + for _, subject := range foundSubjects { + resolvedSubjects[subject.Subject.SubjectObjectId] = subject } - // For subjects found solely via wildcard, check that a wildcard instead exists in - // the result and that the subject is not excluded. - accessibility, _, ok := vctx.accessibilitySet.AccessibiliyAndPermissionshipFor(resource, assertionRel.Subject) - if !ok || accessibility == consistencytestutil.AccessibleViaWildcardOnly { - resolvedSubjectsToCheck := resolvedSubjects + if pageSize == 0 || len(foundSubjects) < int(pageSize) { + break + } + } - // If the assertion has caveat context, rerun LookupSubjects with the context to ensure the returned subject - // matches the context given. - if len(assertion.CaveatContext) > 0 { - resolvedSubjectsWithContext, err := vctx.serviceTester.LookupSubjects(context.Background(), resource, subjectType, vctx.revision, assertion.CaveatContext) - require.NoError(t, err) + // Ensure the subjects found include those defined as expected. Since the + // accessibility set does not include "inferred" subjects (e.g. those with + // permissions as their subject relation, or wildcards), this should be a + // subset. + expectedDefinedSubjects := vctx.accessibilitySet.DirectlyAccessibleDefinedSubjectsOfType(resource, subjectType) + requireSubsetOf(t, maps.Keys(resolvedSubjects), maps.Keys(expectedDefinedSubjects)) + + // Ensure all subjects in true and caveated assertions for the subject type are found + // in the LookupSubject result, except those added via wildcard. + for _, parsedFile := range vctx.clusterAndData.Populated.ParsedFiles { + for _, entry := range []struct { + assertions []blocks.Assertion + requiresPermission bool + }{ + { + assertions: parsedFile.Assertions.AssertTrue, + requiresPermission: true, + }, + { + assertions: parsedFile.Assertions.AssertCaveated, + requiresPermission: false, + }, + } { + for _, assertion := range entry.assertions { + assertionRel := tuple.MustFromRelationship[*v1.ObjectReference, *v1.SubjectReference, *v1.ContextualizedCaveat](assertion.Relationship) + if !assertionRel.ResourceAndRelation.EqualVT(resource) { + continue + } - resolvedSubjectsToCheck = resolvedSubjectsWithContext - } + if assertionRel.Subject.Namespace != subjectType.Namespace || + assertionRel.Subject.Relation != subjectType.Relation { + continue + } - resolvedSubject, ok := resolvedSubjectsToCheck[tuple.PublicWildcard] - require.True(t, ok, "expected wildcard in lookupsubjects response for assertion `%s`", assertion.RelationshipWithContextString) + // For subjects found solely via wildcard, check that a wildcard instead exists in + // the result and that the subject is not excluded. + accessibility, _, ok := vctx.accessibilitySet.AccessibiliyAndPermissionshipFor(resource, assertionRel.Subject) + if !ok || accessibility == consistencytestutil.AccessibleViaWildcardOnly { + resolvedSubjectsToCheck := resolvedSubjects + + // If the assertion has caveat context, rerun LookupSubjects with the context to ensure the returned subject + // matches the context given. + if len(assertion.CaveatContext) > 0 { + resolvedSubjectsWithContext, _, err := vctx.serviceTester.LookupSubjects(context.Background(), resource, subjectType, vctx.revision, assertion.CaveatContext, nil, 0) + require.NoError(t, err) + + resolvedSubjectsToCheck = resolvedSubjectsWithContext + } + + resolvedSubject, ok := resolvedSubjectsToCheck[tuple.PublicWildcard] + require.True(t, ok, "expected wildcard in lookupsubjects response for assertion `%s`", assertion.RelationshipWithContextString) + + if entry.requiresPermission { + require.Equal(t, v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_HAS_PERMISSION, resolvedSubject.Subject.Permissionship) + } + + // Ensure that the subject is not excluded. If a caveated assertion, then the exclusion + // can be caveated. + for _, excludedSubject := range resolvedSubject.ExcludedSubjects { + if entry.requiresPermission { + require.NotEqual(t, excludedSubject.SubjectObjectId, assertionRel.Subject.ObjectId, "wildcard excludes the asserted subject ID: %s", assertionRel.Subject.ObjectId) + } else if excludedSubject.SubjectObjectId == assertionRel.Subject.ObjectId { + require.NotEqual(t, v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_HAS_PERMISSION, excludedSubject.Permissionship, "wildcard concretely excludes the asserted subject ID: %s", assertionRel.Subject.ObjectId) + } + } + continue + } - if entry.requiresPermission { - require.Equal(t, v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_HAS_PERMISSION, resolvedSubject.Subject.Permissionship) + _, ok = resolvedSubjects[assertionRel.Subject.ObjectId] + require.True(t, ok, "missing expected subject %s from assertion %s", assertionRel.Subject.ObjectId, assertion.RelationshipWithContextString) } + } + } - // Ensure that the subject is not excluded. If a caveated assertion, then the exclusion - // can be caveated. - for _, excludedSubject := range resolvedSubject.ExcludedSubjects { - if entry.requiresPermission { - require.NotEqual(t, excludedSubject.SubjectObjectId, assertionRel.Subject.ObjectId, "wildcard excludes the asserted subject ID: %s", assertionRel.Subject.ObjectId) - } else if excludedSubject.SubjectObjectId == assertionRel.Subject.ObjectId { - require.NotEqual(t, v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_HAS_PERMISSION, excludedSubject.Permissionship, "wildcard concretely excludes the asserted subject ID: %s", assertionRel.Subject.ObjectId) - } - } + // Ensure that all excluded subjects from wildcards do not have access. + for _, resolvedSubject := range resolvedSubjects { + if resolvedSubject.Subject.SubjectObjectId != tuple.PublicWildcard { continue } - _, ok = resolvedSubjects[assertionRel.Subject.ObjectId] - require.True(t, ok, "missing expected subject %s from assertion %s", assertionRel.Subject.ObjectId, assertion.RelationshipWithContextString) + for _, excludedSubject := range resolvedSubject.ExcludedSubjects { + permissionship, err := vctx.serviceTester.Check(context.Background(), + resource, + &core.ObjectAndRelation{ + Namespace: subjectType.Namespace, + ObjectId: excludedSubject.SubjectObjectId, + Relation: subjectType.Relation, + }, + vctx.revision, + nil, + ) + require.NoError(t, err) + + expectedPermissionship := v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION + if resolvedSubject.Subject.Permissionship == v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_CONDITIONAL_PERMISSION { + expectedPermissionship = v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION + } + if excludedSubject.Permissionship == v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_CONDITIONAL_PERMISSION { + expectedPermissionship = v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION + } + + require.Equal(t, + expectedPermissionship, + permissionship, + "Found Check failure for resource %s and excluded subject %s in lookup subjects", + tuple.StringONR(resource), + excludedSubject.SubjectObjectId, + ) + } } - } - } - // Ensure that all excluded subjects from wildcards do not have access. - for _, resolvedSubject := range resolvedSubjects { - if resolvedSubject.Subject.SubjectObjectId != tuple.PublicWildcard { - continue - } + // Ensure that every returned defined, non-wildcard subject found checks as expected. + for _, resolvedSubject := range resolvedSubjects { + if resolvedSubject.Subject.SubjectObjectId == tuple.PublicWildcard { + continue + } - for _, excludedSubject := range resolvedSubject.ExcludedSubjects { - permissionship, err := vctx.serviceTester.Check(context.Background(), - resource, - &core.ObjectAndRelation{ + subject := &core.ObjectAndRelation{ Namespace: subjectType.Namespace, - ObjectId: excludedSubject.SubjectObjectId, + ObjectId: resolvedSubject.Subject.SubjectObjectId, Relation: subjectType.Relation, - }, - vctx.revision, - nil, - ) - require.NoError(t, err) - - expectedPermissionship := v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION - if resolvedSubject.Subject.Permissionship == v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_CONDITIONAL_PERMISSION { - expectedPermissionship = v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION - } - if excludedSubject.Permissionship == v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_CONDITIONAL_PERMISSION { - expectedPermissionship = v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION - } - - require.Equal(t, - expectedPermissionship, - permissionship, - "Found Check failure for resource %s and excluded subject %s in lookup subjects", - tuple.StringONR(resource), - excludedSubject.SubjectObjectId, - ) - } - } - - // Ensure that every returned defined, non-wildcard subject found checks as expected. - for _, resolvedSubject := range resolvedSubjects { - if resolvedSubject.Subject.SubjectObjectId == tuple.PublicWildcard { - continue - } - - subject := &core.ObjectAndRelation{ - Namespace: subjectType.Namespace, - ObjectId: resolvedSubject.Subject.SubjectObjectId, - Relation: subjectType.Relation, - } - - permissionship, err := vctx.serviceTester.Check(context.Background(), - resource, - subject, - vctx.revision, - nil, - ) - require.NoError(t, err) + } - expectedPermissionship := v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION - if resolvedSubject.Subject.Permissionship == v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_CONDITIONAL_PERMISSION { - expectedPermissionship = v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION - } + permissionship, err := vctx.serviceTester.Check(context.Background(), + resource, + subject, + vctx.revision, + nil, + ) + require.NoError(t, err) + + expectedPermissionship := v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION + if resolvedSubject.Subject.Permissionship == v1.LookupPermissionship_LOOKUP_PERMISSIONSHIP_CONDITIONAL_PERMISSION { + expectedPermissionship = v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION + } - require.Equal(t, - expectedPermissionship, - permissionship, - "Found Check failure for resource %s and subject %s in lookup subjects", - tuple.StringONR(resource), - tuple.StringONR(subject), - ) + require.Equal(t, + expectedPermissionship, + permissionship, + "Found Check failure for resource %s and subject %s in lookup subjects", + tuple.StringONR(resource), + tuple.StringONR(subject), + ) + } + }) } }) } diff --git a/internal/services/integrationtesting/consistencytestutil/servicetester.go b/internal/services/integrationtesting/consistencytestutil/servicetester.go index c26c012c44..6b4dbfaf5e 100644 --- a/internal/services/integrationtesting/consistencytestutil/servicetester.go +++ b/internal/services/integrationtesting/consistencytestutil/servicetester.go @@ -29,7 +29,7 @@ type ServiceTester interface { Write(ctx context.Context, relationship *core.RelationTuple) error Read(ctx context.Context, namespaceName string, atRevision datastore.Revision) ([]*core.RelationTuple, error) LookupResources(ctx context.Context, resourceRelation *core.RelationReference, subject *core.ObjectAndRelation, atRevision datastore.Revision, cursor *v1.Cursor, limit uint32) ([]*v1.LookupResourcesResponse, *v1.Cursor, error) - LookupSubjects(ctx context.Context, resource *core.ObjectAndRelation, subjectRelation *core.RelationReference, atRevision datastore.Revision, caveatContext map[string]any) (map[string]*v1.LookupSubjectsResponse, error) + LookupSubjects(ctx context.Context, resource *core.ObjectAndRelation, subjectRelation *core.RelationReference, atRevision datastore.Revision, caveatContext map[string]any, cursor *v1.Cursor, limit uint32) (map[string]*v1.LookupSubjectsResponse, *v1.Cursor, error) BulkCheck(ctx context.Context, items []*v1.BulkCheckPermissionRequestItem, atRevision datastore.Revision) ([]*v1.BulkCheckPermissionPair, error) } @@ -192,12 +192,12 @@ func (v1st v1ServiceTester) LookupResources(_ context.Context, resourceRelation return found, lastCursor, nil } -func (v1st v1ServiceTester) LookupSubjects(_ context.Context, resource *core.ObjectAndRelation, subjectRelation *core.RelationReference, atRevision datastore.Revision, caveatContext map[string]any) (map[string]*v1.LookupSubjectsResponse, error) { +func (v1st v1ServiceTester) LookupSubjects(_ context.Context, resource *core.ObjectAndRelation, subjectRelation *core.RelationReference, atRevision datastore.Revision, caveatContext map[string]any, cursor *v1.Cursor, limit uint32) (map[string]*v1.LookupSubjectsResponse, *v1.Cursor, error) { var builtContext *structpb.Struct if caveatContext != nil { built, err := structpb.NewStruct(caveatContext) if err != nil { - return nil, err + return nil, nil, err } builtContext = built } @@ -215,13 +215,16 @@ func (v1st v1ServiceTester) LookupSubjects(_ context.Context, resource *core.Obj AtLeastAsFresh: zedtoken.MustNewFromRevision(atRevision), }, }, - Context: builtContext, + Context: builtContext, + OptionalCursor: cursor, + OptionalConcreteLimit: limit, }) if err != nil { - return nil, err + return nil, nil, err } found := map[string]*v1.LookupSubjectsResponse{} + var lastCursor *v1.Cursor for { resp, err := lookupResp.Recv() if errors.Is(err, io.EOF) { @@ -229,12 +232,13 @@ func (v1st v1ServiceTester) LookupSubjects(_ context.Context, resource *core.Obj } if err != nil { - return nil, err + return nil, nil, err } found[resp.Subject.SubjectObjectId] = resp + lastCursor = resp.AfterResultCursor } - return found, nil + return found, lastCursor, nil } func (v1st v1ServiceTester) BulkCheck(ctx context.Context, items []*v1.BulkCheckPermissionRequestItem, atRevision datastore.Revision) ([]*v1.BulkCheckPermissionPair, error) { diff --git a/internal/services/v1/hash.go b/internal/services/v1/hash.go index e89fa4ccb5..e17ce2f677 100644 --- a/internal/services/v1/hash.go +++ b/internal/services/v1/hash.go @@ -66,6 +66,17 @@ func computeLRRequestHash(req *v1.LookupResourcesRequest) (string, error) { }) } +func computeLSRequestHash(req *v1.LookupSubjectsRequest) (string, error) { + return computeCallHash("v1.lookupsubjects", req.Consistency, map[string]any{ + "subject-type": req.SubjectObjectType, + "permission": req.Permission, + "resource": tuple.StringObjectRef(req.Resource), + "limit": req.OptionalConcreteLimit, + "context": req.Context, + "wildcard-option": int(req.WildcardOption), + }) +} + func computeCallHash(apiName string, consistency *v1.Consistency, arguments map[string]any) (string, error) { stringArguments := make(map[string]string, len(arguments)+1) diff --git a/internal/services/v1/hash_test.go b/internal/services/v1/hash_test.go index 42282ca330..8bb6babd8d 100644 --- a/internal/services/v1/hash_test.go +++ b/internal/services/v1/hash_test.go @@ -813,3 +813,187 @@ func TestBulkCheckPermissionItemWIDHashStability(t *testing.T) { }) } } + +func TestLSHashStability(t *testing.T) { + tcs := []struct { + name string + request *v1.LookupSubjectsRequest + expectedHash string + }{ + { + "basic LS", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 1000, + }, + "15f87f570009e190", + }, + { + "different subject", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject2", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 1000, + }, + "a41898256f42203a", + }, + { + "different permission", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view2", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 1000, + }, + "5dbe04c00a1cd2b0", + }, + { + "different resource type", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource2", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 1000, + }, + "0ede1ecdd53c204f", + }, + { + "different resource id", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc2", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 1000, + }, + "5f957ee550300986", + }, + { + "no limit", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + }, + "dc3f5673a6a3d173", + }, + { + "different limit", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 999, + }, + "3b350c4c36efb985", + }, + { + "default wildcard option", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 1000, + WildcardOption: v1.LookupSubjectsRequest_WILDCARD_OPTION_UNSPECIFIED, + }, + "15f87f570009e190", + }, + { + "different wildcard option", + &v1.LookupSubjectsRequest{ + SubjectObjectType: "subject", + Permission: "view", + Resource: &v1.ObjectReference{ + ObjectType: "resource", + ObjectId: "somedoc", + }, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_MinimizeLatency{ + MinimizeLatency: true, + }, + }, + OptionalConcreteLimit: 1000, + WildcardOption: v1.LookupSubjectsRequest_WILDCARD_OPTION_EXCLUDE_WILDCARDS, + }, + "df28dbb33cdcc8dd", + }, + } + + for _, tc := range tcs { + tc := tc + t.Run(tc.name, func(t *testing.T) { + verr := tc.request.Validate() + require.NoError(t, verr) + + hash, err := computeLSRequestHash(tc.request) + require.NoError(t, err) + require.Equal(t, tc.expectedHash, hash) + }) + } +} diff --git a/internal/services/v1/permissions.go b/internal/services/v1/permissions.go index efb5250896..ba83f23e98 100644 --- a/internal/services/v1/permissions.go +++ b/internal/services/v1/permissions.go @@ -28,6 +28,7 @@ import ( "github.com/authzed/spicedb/pkg/middleware/consistency" core "github.com/authzed/spicedb/pkg/proto/core/v1" dispatch "github.com/authzed/spicedb/pkg/proto/dispatch/v1" + impl "github.com/authzed/spicedb/pkg/proto/impl/v1" "github.com/authzed/spicedb/pkg/tuple" ) @@ -494,86 +495,157 @@ func (ps *permissionServer) LookupSubjects(req *v1.LookupSubjectsRequest, resp v } usagemetrics.SetInContext(ctx, respMetadata) - stream := dispatchpkg.NewHandlingDispatchStream(ctx, func(result *dispatch.DispatchLookupSubjectsResponse) error { - foundSubjects, ok := result.FoundSubjectsByResourceId[req.Resource.ObjectId] - if !ok { - return fmt.Errorf("missing resource ID in returned LS") + var currentCursor *dispatch.Cursor + remainingConcreteLimit := 0 + + lsRequestHash, err := computeLSRequestHash(req) + if err != nil { + return ps.rewriteError(ctx, err) + } + + if req.OptionalCursor != nil { + decodedCursor, err := cursor.DecodeToDispatchCursor(req.OptionalCursor, lsRequestHash) + if err != nil { + return ps.rewriteError(ctx, err) } + currentCursor = decodedCursor + } + + if req.OptionalConcreteLimit > 0 { + remainingConcreteLimit = int(req.OptionalConcreteLimit) + } + + internalResponseCursor := &impl.DecodedCursor{ + VersionOneof: &impl.DecodedCursor_V1{ + V1: &impl.V1Cursor{ + Revision: atRevision.String(), + CallAndParametersHash: lsRequestHash, + }, + }, + } + + for { + countSubjectsFound := 0 - for _, foundSubject := range foundSubjects.FoundSubjects { - excludedSubjectIDs := make([]string, 0, len(foundSubject.ExcludedSubjects)) - for _, excludedSubject := range foundSubject.ExcludedSubjects { - excludedSubjectIDs = append(excludedSubjectIDs, excludedSubject.SubjectId) + ctxWithCancel, cancel := context.WithCancel(ctx) + defer cancel() + + stream := dispatchpkg.NewHandlingDispatchStream(ctxWithCancel, func(result *dispatch.DispatchLookupSubjectsResponse) error { + foundSubjects, ok := result.FoundSubjectsByResourceId[req.Resource.ObjectId] + if !ok { + return fmt.Errorf("missing resource ID in returned LS") } - excludedSubjects := make([]*v1.ResolvedSubject, 0, len(foundSubject.ExcludedSubjects)) - for _, excludedSubject := range foundSubject.ExcludedSubjects { - resolvedExcludedSubject, err := foundSubjectToResolvedSubject(ctx, excludedSubject, caveatContext, ds) + for _, foundSubject := range foundSubjects.FoundSubjects { + // Skip wildcards if requested they be skipped. + if req.WildcardOption == v1.LookupSubjectsRequest_WILDCARD_OPTION_EXCLUDE_WILDCARDS && foundSubject.SubjectId == tuple.PublicWildcard { + continue + } + + excludedSubjectIDs := make([]string, 0, len(foundSubject.ExcludedSubjects)) + excludedSubjects := make([]*v1.ResolvedSubject, 0, len(foundSubject.ExcludedSubjects)) + for _, excludedSubject := range foundSubject.ExcludedSubjects { + excludedSubjectIDs = append(excludedSubjectIDs, excludedSubject.SubjectId) + + resolvedExcludedSubject, err := foundSubjectToResolvedSubject(ctx, excludedSubject, caveatContext, ds) + if err != nil { + return fmt.Errorf("error when resolving excluded subject: %w", err) + } + + if resolvedExcludedSubject == nil { + continue + } + + excludedSubjects = append(excludedSubjects, resolvedExcludedSubject) + } + + subject, err := foundSubjectToResolvedSubject(ctx, foundSubject, caveatContext, ds) + if err != nil { + return fmt.Errorf("error when resolving subject: %w", err) + } + if subject == nil { + continue + } + + // NOTE: we need to recompute the cursor here because we get multiple results back from DispatchLookupSubjects + // in one message. + dispatchCursor, err := graph.CursorForFoundSubjectID(subject.SubjectObjectId, result.AfterResponseCursor) if err != nil { return err } - if resolvedExcludedSubject == nil { - continue + // Update the existing internal cursor for encoding. + internalResponseCursor.GetV1().DispatchVersion = dispatchCursor.DispatchVersion + internalResponseCursor.GetV1().Sections = dispatchCursor.Sections + + encodedCursor, err := cursor.Encode(internalResponseCursor) + if err != nil { + return err } - excludedSubjects = append(excludedSubjects, resolvedExcludedSubject) - } + currentCursor = dispatchCursor - subject, err := foundSubjectToResolvedSubject(ctx, foundSubject, caveatContext, ds) - if err != nil { - return err - } - if subject == nil { - continue - } + if subject.SubjectObjectId != tuple.PublicWildcard { + countSubjectsFound++ + if req.OptionalConcreteLimit > 0 && remainingConcreteLimit <= 0 { + return nil + } + remainingConcreteLimit-- + } - err = resp.Send(&v1.LookupSubjectsResponse{ - Subject: subject, - ExcludedSubjects: excludedSubjects, - LookedUpAt: revisionReadAt, - SubjectObjectId: foundSubject.SubjectId, // Deprecated - ExcludedSubjectIds: excludedSubjectIDs, // Deprecated - Permissionship: subject.Permissionship, // Deprecated - PartialCaveatInfo: subject.PartialCaveatInfo, // Deprecated - }) - if err != nil { - return err + err = resp.Send(&v1.LookupSubjectsResponse{ + Subject: subject, + ExcludedSubjects: excludedSubjects, + LookedUpAt: revisionReadAt, + SubjectObjectId: foundSubject.SubjectId, // Deprecated + ExcludedSubjectIds: excludedSubjectIDs, // Deprecated + Permissionship: subject.Permissionship, // Deprecated + PartialCaveatInfo: subject.PartialCaveatInfo, // Deprecated + AfterResultCursor: encodedCursor, + }) + if err != nil { + return err + } } - } - - dispatchpkg.AddResponseMetadata(respMetadata, result.Metadata) - return nil - }) - bf, err := dispatch.NewTraversalBloomFilter(uint(ps.config.MaximumAPIDepth)) - if err != nil { - return err - } + dispatchpkg.AddResponseMetadata(respMetadata, result.Metadata) + return nil + }) - err = ps.dispatch.DispatchLookupSubjects( - &dispatch.DispatchLookupSubjectsRequest{ - Metadata: &dispatch.ResolverMeta{ - AtRevision: atRevision.String(), - DepthRemaining: ps.config.MaximumAPIDepth, - TraversalBloom: bf, - }, - ResourceRelation: &core.RelationReference{ - Namespace: req.Resource.ObjectType, - Relation: req.Permission, - }, - ResourceIds: []string{req.Resource.ObjectId}, - SubjectRelation: &core.RelationReference{ - Namespace: req.SubjectObjectType, - Relation: stringz.DefaultEmpty(req.OptionalSubjectRelation, tuple.Ellipsis), + err = ps.dispatch.DispatchLookupSubjects( + &dispatch.DispatchLookupSubjectsRequest{ + Metadata: &dispatch.ResolverMeta{ + AtRevision: atRevision.String(), + DepthRemaining: ps.config.MaximumAPIDepth, + }, + ResourceRelation: &core.RelationReference{ + Namespace: req.Resource.ObjectType, + Relation: req.Permission, + }, + ResourceIds: []string{req.Resource.ObjectId}, + SubjectRelation: &core.RelationReference{ + Namespace: req.SubjectObjectType, + Relation: stringz.DefaultEmpty(req.OptionalSubjectRelation, tuple.Ellipsis), + }, + OptionalCursor: currentCursor, + OptionalLimit: req.OptionalConcreteLimit, }, - }, - stream) - if err != nil { - return ps.rewriteError(ctx, err) - } + stream) + if err != nil { + return ps.rewriteError(ctx, err) + } - return nil + // If no concrete limit was requested, then all results are streamed in a single call to match + // older behavior. + if req.OptionalConcreteLimit == 0 { + return nil + } + + // If no subjects were found, then we're done. + if countSubjectsFound == 0 || remainingConcreteLimit <= 0 { + return nil + } + } } func foundSubjectToResolvedSubject(ctx context.Context, foundSubject *dispatch.FoundSubject, caveatContext map[string]any, ds datastore.CaveatReader) (*v1.ResolvedSubject, error) { diff --git a/internal/services/v1/permissions_test.go b/internal/services/v1/permissions_test.go index 95dbe2b23b..6450fe3930 100644 --- a/internal/services/v1/permissions_test.go +++ b/internal/services/v1/permissions_test.go @@ -8,6 +8,7 @@ import ( "io" "math/rand" "slices" + "sort" "strings" "testing" "time" @@ -28,6 +29,7 @@ import ( v1svc "github.com/authzed/spicedb/internal/services/v1" tf "github.com/authzed/spicedb/internal/testfixtures" "github.com/authzed/spicedb/internal/testserver" + "github.com/authzed/spicedb/internal/testutil" "github.com/authzed/spicedb/pkg/datastore" "github.com/authzed/spicedb/pkg/genutil/mapz" pgraph "github.com/authzed/spicedb/pkg/graph" @@ -1631,3 +1633,659 @@ func TestLookupResourcesDeduplication(t *testing.T) { require.Equal(t, []string{"first"}, foundObjectIds.AsSlice()) } + +func TestLookupSubjectsWithCursors(t *testing.T) { + testCases := []struct { + resource *v1.ObjectReference + permission string + subjectType string + subjectRelation string + + expectedSubjectIds []string + }{ + { + obj("document", "companyplan"), + "view", + "user", + "", + []string{"auditor", "legal", "owner"}, + }, + { + obj("document", "healthplan"), + "view", + "user", + "", + []string{"chief_financial_officer"}, + }, + { + obj("document", "masterplan"), + "view", + "user", + "", + []string{"auditor", "chief_financial_officer", "eng_lead", "legal", "owner", "product_manager", "vp_product"}, + }, + { + obj("document", "masterplan"), + "view_and_edit", + "user", + "", + nil, + }, + { + obj("document", "specialplan"), + "view_and_edit", + "user", + "", + []string{"multiroleguy"}, + }, + { + obj("document", "unknownobj"), + "view", + "user", + "", + nil, + }, + } + + for _, delta := range testTimedeltas { + delta := delta + t.Run(fmt.Sprintf("fuzz%d", delta/time.Millisecond), func(t *testing.T) { + for _, limit := range []int{1, 2, 5, 10, 100} { + limit := limit + t.Run(fmt.Sprintf("limit%d_", limit), func(t *testing.T) { + for _, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("%s:%s#%s for %s#%s", tc.resource.ObjectType, tc.resource.ObjectId, tc.permission, tc.subjectType, tc.subjectRelation), func(t *testing.T) { + require := require.New(t) + conn, cleanup, _, revision := testserver.NewTestServer(require, delta, memdb.DisableGC, true, tf.StandardDatastoreWithData) + client := v1.NewPermissionsServiceClient(conn) + t.Cleanup(func() { + goleak.VerifyNone(t, goleak.IgnoreCurrent()) + }) + t.Cleanup(cleanup) + + var currentCursor *v1.Cursor + foundObjectIds := mapz.NewSet[string]() + + for i := 0; i < 15; i++ { + var trailer metadata.MD + lookupClient, err := client.LookupSubjects(context.Background(), &v1.LookupSubjectsRequest{ + Resource: tc.resource, + Permission: tc.permission, + SubjectObjectType: tc.subjectType, + OptionalSubjectRelation: tc.subjectRelation, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_AtLeastAsFresh{ + AtLeastAsFresh: zedtoken.MustNewFromRevision(revision), + }, + }, + OptionalConcreteLimit: uint32(limit), + OptionalCursor: currentCursor, + }, grpc.Trailer(&trailer)) + + require.NoError(err) + var resolvedObjectIds []string + existingCursor := currentCursor + for { + resp, err := lookupClient.Recv() + if errors.Is(err, io.EOF) { + break + } + + require.NoError(err) + + resolvedObjectIds = append(resolvedObjectIds, resp.Subject.SubjectObjectId) + foundObjectIds.Add(resp.Subject.SubjectObjectId) + currentCursor = resp.AfterResultCursor + } + + require.LessOrEqual(len(resolvedObjectIds), limit, "starting at cursor %v, found: %v", existingCursor, resolvedObjectIds) + + dispatchCount, err := responsemeta.GetIntResponseTrailerMetadata(trailer, responsemeta.DispatchedOperationsCount) + require.NoError(err) + require.GreaterOrEqual(dispatchCount, 0) + + if len(resolvedObjectIds) == 0 { + break + } + } + + allResolvedObjectIds := foundObjectIds.AsSlice() + + sort.Strings(tc.expectedSubjectIds) + sort.Strings(allResolvedObjectIds) + + require.Equal(tc.expectedSubjectIds, allResolvedObjectIds) + }) + } + }) + } + }) + } +} + +func TestLookupSubjectsWithCursorsOverSchema(t *testing.T) { + testCases := []struct { + name string + schema string + relationships []*core.RelationTuple + + resource *v1.ObjectReference + permission string + subjectType string + subjectRelation string + + expectedSubjectIds []string + }{ + { + "basic lookup", + ` + definition user {} + + definition document { + relation viewer: user + permission view = viewer + } + `, + testutil.GenResourceTuples("document", "somedoc", "viewer", "user", "...", 1000), + + obj("document", "somedoc"), + "view", + "user", + "", + testutil.GenSubjectIds("user", 1000), + }, + { + "lookup over union", + ` + definition user {} + + definition document { + relation editor: user + relation viewer: user + permission view = viewer + editor + } + `, + testutil.JoinTuples( + testutil.GenResourceTuples("document", "somedoc", "viewer", "user", "...", 580), + testutil.GenResourceTuplesWithOffset("document", "somedoc", "editor", "user", "...", 500, 500), + ), + + obj("document", "somedoc"), + "view", + "user", + "", + testutil.GenSubjectIds("user", 1000), + }, + { + "lookup over intersection", + ` + definition user {} + + definition document { + relation editor: user + relation viewer: user + permission view = viewer & editor + } + `, + testutil.JoinTuples( + testutil.GenResourceTuples("document", "somedoc", "viewer", "user", "...", 580), + testutil.GenResourceTuplesWithOffset("document", "somedoc", "editor", "user", "...", 500, 500), + ), + + obj("document", "somedoc"), + "view", + "user", + "", + testutil.GenSubjectIdsWithOffset("user", 500, 80), + }, + { + "lookup over exclusion", + ` + definition user {} + + definition document { + relation banned: user + relation viewer: user + permission view = viewer - banned + } + `, + testutil.JoinTuples( + testutil.GenResourceTuples("document", "somedoc", "viewer", "user", "...", 580), + testutil.GenResourceTuplesWithOffset("document", "somedoc", "banned", "user", "...", 500, 500), + ), + + obj("document", "somedoc"), + "view", + "user", + "", + testutil.GenSubjectIdsWithOffset("user", 0, 500), + }, + { + "lookup over union with arrow", + ` + definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + relation editor: user + relation viewer: user + permission view = viewer + editor + org->admin + } + `, + testutil.JoinTuples( + testutil.GenResourceTuples("document", "somedoc", "viewer", "user", "...", 580), + testutil.GenResourceTuplesWithOffset("document", "somedoc", "editor", "user", "...", 500, 500), + testutil.GenResourceTuplesWithOffset("organization", "someorg", "admin", "user", "...", 700, 500), + []*core.RelationTuple{ + tuple.MustParse("document:somedoc#org@organization:someorg"), + }, + ), + + obj("document", "somedoc"), + "view", + "user", + "", + testutil.GenSubjectIds("user", 1200), + }, + { + "lookup over groups", + ` + definition user {} + + definition group { + relation direct_member: user | group#member + permission member = direct_member + } + + definition document { + relation viewer: user | group#member + permission view = viewer + } + `, + testutil.JoinTuples( + testutil.GenResourceTuples("document", "somedoc", "viewer", "user", "...", 580), + testutil.GenResourceTuplesWithOffset("document", "somedoc", "viewer", "user", "...", 1200, 100), + testutil.GenResourceTuplesWithOffset("group", "somegroup", "direct_member", "user", "...", 500, 500), + testutil.GenResourceTuplesWithOffset("group", "childgroup", "direct_member", "user", "...", 700, 500), + []*core.RelationTuple{ + tuple.MustParse("document:somedoc#viewer@group:somegroup#member"), + tuple.MustParse("group:somegroup#direct_member@group:childgroup#member"), + }, + ), + + obj("document", "somedoc"), + "view", + "user", + "", + testutil.GenSubjectIds("user", 1300), + }, + { + "complex schema with disjoint user sets", + ` + definition user {} + + definition group { + relation owner: user + relation parent: group + relation direct_member: user | group#member + permission member = owner + direct_member + parent->member + } + + definition supercontainer { + relation owner: user | group#member + } + + definition container { + relation parent: supercontainer + relation direct_member: user | group#member + relation owner: user | group#member + + permission special_ownership = parent->owner + permission member = owner + direct_member + } + + definition resource { + relation parent: container + relation viewer: user | group#member + relation owner: user | group#member + + permission view = owner + parent->member + viewer + parent->special_ownership + } + `, + testutil.JoinTuples( + []*core.RelationTuple{ + tuple.MustParse("resource:someresource#owner@user:31#..."), + tuple.MustParse("resource:someresource#parent@container:17#..."), + tuple.MustParse("container:17#direct_member@group:81#member"), + tuple.MustParse("container:17#direct_member@user:11#..."), + tuple.MustParse("container:17#direct_member@user:129#..."), + tuple.MustParse("container:17#direct_member@user:13#..."), + tuple.MustParse("container:17#direct_member@user:130#..."), + tuple.MustParse("container:17#direct_member@user:131#..."), + tuple.MustParse("container:17#direct_member@user:133#..."), + tuple.MustParse("container:17#direct_member@user:134#..."), + tuple.MustParse("container:17#direct_member@user:135#..."), + tuple.MustParse("container:17#direct_member@user:15#..."), + tuple.MustParse("container:17#direct_member@user:16#..."), + tuple.MustParse("container:17#direct_member@user:160#..."), + tuple.MustParse("container:17#direct_member@user:163#..."), + tuple.MustParse("container:17#direct_member@user:166#..."), + tuple.MustParse("container:17#direct_member@user:17#..."), + tuple.MustParse("container:17#direct_member@user:18#..."), + tuple.MustParse("container:17#direct_member@user:19#..."), + tuple.MustParse("container:17#direct_member@user:20#..."), + tuple.MustParse("container:17#direct_member@user:23#..."), + tuple.MustParse("container:17#direct_member@user:244#..."), + tuple.MustParse("container:17#direct_member@user:25#..."), + tuple.MustParse("container:17#direct_member@user:26#..."), + tuple.MustParse("container:17#direct_member@user:262#..."), + tuple.MustParse("container:17#direct_member@user:264#..."), + tuple.MustParse("container:17#direct_member@user:265#..."), + tuple.MustParse("container:17#direct_member@user:267#..."), + tuple.MustParse("container:17#direct_member@user:268#..."), + tuple.MustParse("container:17#direct_member@user:269#..."), + tuple.MustParse("container:17#direct_member@user:27#..."), + tuple.MustParse("container:17#direct_member@user:298#..."), + tuple.MustParse("container:17#direct_member@user:30#..."), + tuple.MustParse("container:17#direct_member@user:31#..."), + tuple.MustParse("container:17#direct_member@user:317#..."), + tuple.MustParse("container:17#direct_member@user:318#..."), + tuple.MustParse("container:17#direct_member@user:32#..."), + tuple.MustParse("container:17#direct_member@user:324#..."), + tuple.MustParse("container:17#direct_member@user:33#..."), + tuple.MustParse("container:17#direct_member@user:34#..."), + tuple.MustParse("container:17#direct_member@user:341#..."), + tuple.MustParse("container:17#direct_member@user:342#..."), + tuple.MustParse("container:17#direct_member@user:343#..."), + tuple.MustParse("container:17#direct_member@user:349#..."), + tuple.MustParse("container:17#direct_member@user:357#..."), + tuple.MustParse("container:17#direct_member@user:361#..."), + tuple.MustParse("container:17#direct_member@user:388#..."), + tuple.MustParse("container:17#direct_member@user:410#..."), + tuple.MustParse("container:17#direct_member@user:430#..."), + tuple.MustParse("container:17#direct_member@user:438#..."), + tuple.MustParse("container:17#direct_member@user:446#..."), + tuple.MustParse("container:17#direct_member@user:448#..."), + tuple.MustParse("container:17#direct_member@user:451#..."), + tuple.MustParse("container:17#direct_member@user:452#..."), + tuple.MustParse("container:17#direct_member@user:453#..."), + tuple.MustParse("container:17#direct_member@user:456#..."), + tuple.MustParse("container:17#direct_member@user:458#..."), + tuple.MustParse("container:17#direct_member@user:459#..."), + tuple.MustParse("container:17#direct_member@user:462#..."), + tuple.MustParse("container:17#direct_member@user:470#..."), + tuple.MustParse("container:17#direct_member@user:471#..."), + tuple.MustParse("container:17#direct_member@user:474#..."), + tuple.MustParse("container:17#direct_member@user:475#..."), + tuple.MustParse("container:17#direct_member@user:476#..."), + tuple.MustParse("container:17#direct_member@user:477#..."), + tuple.MustParse("container:17#direct_member@user:478#..."), + tuple.MustParse("container:17#direct_member@user:480#..."), + tuple.MustParse("container:17#direct_member@user:485#..."), + tuple.MustParse("container:17#direct_member@user:488#..."), + tuple.MustParse("container:17#direct_member@user:490#..."), + tuple.MustParse("container:17#direct_member@user:494#..."), + tuple.MustParse("container:17#direct_member@user:496#..."), + tuple.MustParse("container:17#direct_member@user:506#..."), + tuple.MustParse("container:17#direct_member@user:508#..."), + tuple.MustParse("container:17#direct_member@user:513#..."), + tuple.MustParse("container:17#direct_member@user:514#..."), + tuple.MustParse("container:17#direct_member@user:518#..."), + tuple.MustParse("container:17#direct_member@user:528#..."), + tuple.MustParse("container:17#direct_member@user:530#..."), + tuple.MustParse("container:17#direct_member@user:537#..."), + tuple.MustParse("container:17#direct_member@user:545#..."), + tuple.MustParse("container:17#direct_member@user:614#..."), + tuple.MustParse("container:17#direct_member@user:616#..."), + tuple.MustParse("container:17#direct_member@user:619#..."), + tuple.MustParse("container:17#direct_member@user:620#..."), + tuple.MustParse("container:17#direct_member@user:621#..."), + tuple.MustParse("container:17#direct_member@user:622#..."), + tuple.MustParse("container:17#direct_member@user:624#..."), + tuple.MustParse("container:17#direct_member@user:625#..."), + tuple.MustParse("container:17#direct_member@user:626#..."), + tuple.MustParse("container:17#direct_member@user:629#..."), + tuple.MustParse("container:17#direct_member@user:630#..."), + tuple.MustParse("container:17#direct_member@user:633#..."), + tuple.MustParse("container:17#direct_member@user:635#..."), + tuple.MustParse("container:17#direct_member@user:644#..."), + tuple.MustParse("container:17#direct_member@user:645#..."), + tuple.MustParse("container:17#direct_member@user:646#..."), + tuple.MustParse("container:17#direct_member@user:647#..."), + tuple.MustParse("container:17#direct_member@user:649#..."), + tuple.MustParse("container:17#direct_member@user:652#..."), + tuple.MustParse("container:17#direct_member@user:653#..."), + tuple.MustParse("container:17#direct_member@user:656#..."), + tuple.MustParse("container:17#direct_member@user:657#..."), + tuple.MustParse("container:17#direct_member@user:672#..."), + tuple.MustParse("container:17#direct_member@user:680#..."), + tuple.MustParse("container:17#direct_member@user:687#..."), + tuple.MustParse("container:17#direct_member@user:690#..."), + tuple.MustParse("container:17#direct_member@user:691#..."), + tuple.MustParse("container:17#direct_member@user:698#..."), + tuple.MustParse("container:17#direct_member@user:699#..."), + tuple.MustParse("container:17#direct_member@user:7#..."), + tuple.MustParse("container:17#direct_member@user:700#..."), + tuple.MustParse("container:17#owner@user:3#..."), + tuple.MustParse("container:17#owner@user:378#..."), + tuple.MustParse("container:17#owner@user:410#..."), + tuple.MustParse("container:17#owner@user:651#..."), + tuple.MustParse("container:17#parent@supercontainer:22#..."), + tuple.MustParse("group:81#direct_member@user:11#..."), + tuple.MustParse("group:81#direct_member@user:129#..."), + tuple.MustParse("group:81#direct_member@user:13#..."), + tuple.MustParse("group:81#direct_member@user:130#..."), + tuple.MustParse("group:81#direct_member@user:131#..."), + tuple.MustParse("group:81#direct_member@user:133#..."), + tuple.MustParse("group:81#direct_member@user:134#..."), + tuple.MustParse("group:81#direct_member@user:135#..."), + tuple.MustParse("group:81#direct_member@user:15#..."), + tuple.MustParse("group:81#direct_member@user:156#..."), + tuple.MustParse("group:81#direct_member@user:16#..."), + tuple.MustParse("group:81#direct_member@user:163#..."), + tuple.MustParse("group:81#direct_member@user:166#..."), + tuple.MustParse("group:81#direct_member@user:167#..."), + tuple.MustParse("group:81#direct_member@user:18#..."), + tuple.MustParse("group:81#direct_member@user:19#..."), + tuple.MustParse("group:81#direct_member@user:20#..."), + tuple.MustParse("group:81#direct_member@user:23#..."), + tuple.MustParse("group:81#direct_member@user:24#..."), + tuple.MustParse("group:81#direct_member@user:244#..."), + tuple.MustParse("group:81#direct_member@user:25#..."), + tuple.MustParse("group:81#direct_member@user:26#..."), + tuple.MustParse("group:81#direct_member@user:262#..."), + tuple.MustParse("group:81#direct_member@user:264#..."), + tuple.MustParse("group:81#direct_member@user:265#..."), + tuple.MustParse("group:81#direct_member@user:267#..."), + tuple.MustParse("group:81#direct_member@user:268#..."), + tuple.MustParse("group:81#direct_member@user:269#..."), + tuple.MustParse("group:81#direct_member@user:27#..."), + tuple.MustParse("group:81#direct_member@user:285#..."), + tuple.MustParse("group:81#direct_member@user:286#..."), + tuple.MustParse("group:81#direct_member@user:287#..."), + tuple.MustParse("group:81#direct_member@user:298#..."), + tuple.MustParse("group:81#direct_member@user:30#..."), + tuple.MustParse("group:81#direct_member@user:31#..."), + tuple.MustParse("group:81#direct_member@user:310#..."), + tuple.MustParse("group:81#direct_member@user:317#..."), + tuple.MustParse("group:81#direct_member@user:318#..."), + tuple.MustParse("group:81#direct_member@user:32#..."), + tuple.MustParse("group:81#direct_member@user:324#..."), + tuple.MustParse("group:81#direct_member@user:34#..."), + tuple.MustParse("group:81#direct_member@user:341#..."), + tuple.MustParse("group:81#direct_member@user:342#..."), + tuple.MustParse("group:81#direct_member@user:343#..."), + tuple.MustParse("group:81#direct_member@user:349#..."), + tuple.MustParse("group:81#direct_member@user:371#..."), + tuple.MustParse("group:81#direct_member@user:382#..."), + tuple.MustParse("group:81#direct_member@user:388#..."), + tuple.MustParse("group:81#direct_member@user:4#..."), + tuple.MustParse("group:81#direct_member@user:411#..."), + tuple.MustParse("group:81#direct_member@user:437#..."), + tuple.MustParse("group:81#direct_member@user:438#..."), + tuple.MustParse("group:81#direct_member@user:440#..."), + tuple.MustParse("group:81#direct_member@user:452#..."), + tuple.MustParse("group:81#direct_member@user:481#..."), + tuple.MustParse("group:81#direct_member@user:486#..."), + tuple.MustParse("group:81#direct_member@user:487#..."), + tuple.MustParse("group:81#direct_member@user:529#..."), + tuple.MustParse("group:81#direct_member@user:7#..."), + tuple.MustParse("group:81#parent@group:1#..."), + tuple.MustParse("supercontainer:22#direct_member@user:279#..."), + tuple.MustParse("supercontainer:22#direct_member@user:438#..."), + tuple.MustParse("supercontainer:22#direct_member@user:472#..."), + tuple.MustParse("supercontainer:22#direct_member@user:485#..."), + tuple.MustParse("supercontainer:22#direct_member@user:489#..."), + tuple.MustParse("supercontainer:22#direct_member@user:526#..."), + tuple.MustParse("supercontainer:22#direct_member@user:536#..."), + tuple.MustParse("supercontainer:22#direct_member@user:537#..."), + tuple.MustParse("supercontainer:22#direct_member@user:623#..."), + tuple.MustParse("supercontainer:22#direct_member@user:672#..."), + tuple.MustParse("supercontainer:22#owner@group:3#member"), + tuple.MustParse("supercontainer:22#owner@user:136#..."), + tuple.MustParse("supercontainer:22#owner@user:19#..."), + tuple.MustParse("supercontainer:22#owner@user:21#..."), + tuple.MustParse("supercontainer:22#owner@user:279#..."), + tuple.MustParse("supercontainer:22#owner@user:3#..."), + tuple.MustParse("supercontainer:22#owner@user:31#..."), + tuple.MustParse("supercontainer:22#owner@user:4#..."), + tuple.MustParse("supercontainer:22#owner@user:439#..."), + tuple.MustParse("supercontainer:22#owner@user:500#..."), + tuple.MustParse("supercontainer:22#owner@user:7#..."), + tuple.MustParse("supercontainer:22#owner@user:9#..."), + tuple.MustParse("group:3#direct_member@user:135#..."), + tuple.MustParse("group:3#direct_member@user:160#..."), + tuple.MustParse("group:3#direct_member@user:17#..."), + tuple.MustParse("group:3#direct_member@user:19#..."), + tuple.MustParse("group:3#direct_member@user:272#..."), + tuple.MustParse("group:3#direct_member@user:3#..."), + tuple.MustParse("group:3#direct_member@user:4#..."), + tuple.MustParse("group:3#direct_member@user:439#..."), + tuple.MustParse("group:3#direct_member@user:7#..."), + tuple.MustParse("group:3#direct_member@user:9#..."), + tuple.MustParse("group:1#direct_member@user:12#..."), + tuple.MustParse("group:1#direct_member@user:13#..."), + tuple.MustParse("group:1#direct_member@user:135#..."), + tuple.MustParse("group:1#direct_member@user:14#..."), + tuple.MustParse("group:1#direct_member@user:21#..."), + tuple.MustParse("group:1#direct_member@user:320#..."), + tuple.MustParse("group:1#direct_member@user:321#..."), + tuple.MustParse("group:1#direct_member@user:322#..."), + tuple.MustParse("group:1#direct_member@user:323#..."), + tuple.MustParse("group:1#direct_member@user:34#..."), + tuple.MustParse("group:1#direct_member@user:397#..."), + tuple.MustParse("group:1#direct_member@user:46#..."), + tuple.MustParse("group:1#direct_member@user:50#..."), + tuple.MustParse("group:1#direct_member@user:662#..."), + tuple.MustParse("group:1#owner@user:135#..."), + tuple.MustParse("group:1#owner@user:148#..."), + tuple.MustParse("group:1#owner@user:160#..."), + tuple.MustParse("group:1#owner@user:17#..."), + tuple.MustParse("group:1#owner@user:25#..."), + tuple.MustParse("group:1#owner@user:279#..."), + tuple.MustParse("group:1#owner@user:3#..."), + tuple.MustParse("group:1#owner@user:31#..."), + tuple.MustParse("group:1#owner@user:4#..."), + tuple.MustParse("group:1#owner@user:406#..."), + tuple.MustParse("group:1#owner@user:439#..."), + tuple.MustParse("group:1#owner@user:7#..."), + tuple.MustParse("group:1#owner@user:9#..."), + }, + ), + + obj("resource", "someresource"), + "view", + "user", + "", + []string{"11", "12", "129", "13", "130", "131", "133", "134", "135", "136", "14", "148", "15", "156", "16", "160", "163", "166", "167", "17", "18", "19", "20", "21", "23", "24", "244", "25", "26", "262", "264", "265", "267", "268", "269", "27", "272", "279", "285", "286", "287", "298", "3", "30", "31", "310", "317", "318", "32", "320", "321", "322", "323", "324", "33", "34", "341", "342", "343", "349", "357", "361", "371", "378", "382", "388", "397", "4", "406", "410", "411", "430", "437", "438", "439", "440", "446", "448", "451", "452", "453", "456", "458", "459", "46", "462", "470", "471", "474", "475", "476", "477", "478", "480", "481", "485", "486", "487", "488", "490", "494", "496", "50", "500", "506", "508", "513", "514", "518", "528", "529", "530", "537", "545", "614", "616", "619", "620", "621", "622", "624", "625", "626", "629", "630", "633", "635", "644", "645", "646", "647", "649", "651", "652", "653", "656", "657", "662", "672", "680", "687", "690", "691", "698", "699", "7", "700", "9"}, + }, + } + + for _, delta := range testTimedeltas { + delta := delta + t.Run(fmt.Sprintf("fuzz%d", delta/time.Millisecond), func(t *testing.T) { + for _, limit := range []int{0, 5, 10, 15, 104, 572} { + limit := limit + t.Run(fmt.Sprintf("limit%d_", limit), func(t *testing.T) { + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + req := require.New(t) + conn, cleanup, _, revision := testserver.NewTestServer(req, testTimedeltas[0], memdb.DisableGC, true, + func(ds datastore.Datastore, require *require.Assertions) (datastore.Datastore, datastore.Revision) { + return tf.DatastoreFromSchemaAndTestRelationships(ds, tc.schema, tc.relationships, require) + }) + + client := v1.NewPermissionsServiceClient(conn) + t.Cleanup(func() { + goleak.VerifyNone(t, goleak.IgnoreCurrent()) + }) + t.Cleanup(cleanup) + + var currentCursor *v1.Cursor + foundObjectIds := mapz.NewSet[string]() + + for i := 0; i < 500; i++ { + var trailer metadata.MD + + lookupClient, err := client.LookupSubjects(context.Background(), &v1.LookupSubjectsRequest{ + Resource: tc.resource, + Permission: tc.permission, + SubjectObjectType: tc.subjectType, + OptionalSubjectRelation: tc.subjectRelation, + Consistency: &v1.Consistency{ + Requirement: &v1.Consistency_AtLeastAsFresh{ + AtLeastAsFresh: zedtoken.MustNewFromRevision(revision), + }, + }, + OptionalConcreteLimit: uint32(limit), + OptionalCursor: currentCursor, + }, grpc.Trailer(&trailer)) + + req.NoError(err) + var resolvedObjectIds []string + existingCursor := currentCursor + for { + resp, err := lookupClient.Recv() + if errors.Is(err, io.EOF) { + break + } + + req.NoError(err) + + resolvedObjectIds = append(resolvedObjectIds, resp.Subject.SubjectObjectId) + foundObjectIds.Add(resp.Subject.SubjectObjectId) + currentCursor = resp.AfterResultCursor + } + + if limit > 0 { + req.LessOrEqual(len(resolvedObjectIds), limit, "starting at cursor %v, found: %v", existingCursor, resolvedObjectIds) + } + + dispatchCount, err := responsemeta.GetIntResponseTrailerMetadata(trailer, responsemeta.DispatchedOperationsCount) + req.NoError(err) + req.GreaterOrEqual(dispatchCount, 0) + + if len(resolvedObjectIds) == 0 || limit == 0 { + break + } + } + + allResolvedObjectIds := foundObjectIds.AsSlice() + + sort.Strings(tc.expectedSubjectIds) + sort.Strings(allResolvedObjectIds) + + req.Equal(tc.expectedSubjectIds, allResolvedObjectIds) + }) + } + }) + } + }) + } +} diff --git a/internal/testutil/tuples.go b/internal/testutil/tuples.go new file mode 100644 index 0000000000..aa46303539 --- /dev/null +++ b/internal/testutil/tuples.go @@ -0,0 +1,91 @@ +package testutil + +import ( + "fmt" + + "golang.org/x/exp/slices" + + core "github.com/authzed/spicedb/pkg/proto/core/v1" + "github.com/authzed/spicedb/pkg/tuple" +) + +var ONR = tuple.ObjectAndRelation + +func JoinTuples(first []*core.RelationTuple, others ...[]*core.RelationTuple) []*core.RelationTuple { + combined := slices.Clone(first) + for _, other := range others { + combined = append(combined, other...) + } + return combined +} + +func GenTuplesWithOffset(resourceName string, relation string, subjectName string, subjectID string, offset int, number int) []*core.RelationTuple { + return GenTuplesWithCaveat(resourceName, relation, subjectName, subjectID, "", nil, offset, number) +} + +func GenTuples(resourceName string, relation string, subjectName string, subjectID string, number int) []*core.RelationTuple { + return GenTuplesWithOffset(resourceName, relation, subjectName, subjectID, 0, number) +} + +func GenResourceTuples(resourceName string, resourceID string, relation string, subjectName string, subjectRelation string, number int) []*core.RelationTuple { + return GenResourceTuplesWithOffset(resourceName, resourceID, relation, subjectName, subjectRelation, 0, number) +} + +func GenResourceTuplesWithOffset(resourceName string, resourceID string, relation string, subjectName string, subjectRelation string, offset int, number int) []*core.RelationTuple { + tuples := make([]*core.RelationTuple, 0, number) + for i := 0; i < number; i++ { + tpl := &core.RelationTuple{ + ResourceAndRelation: ONR(resourceName, resourceID, relation), + Subject: ONR(subjectName, fmt.Sprintf("%s-%d", subjectName, i+offset), subjectRelation), + } + tuples = append(tuples, tpl) + } + return tuples +} + +func GenSubjectTuples(resourceName string, relation string, subjectName string, subjectRelation string, number int) []*core.RelationTuple { + tuples := make([]*core.RelationTuple, 0, number) + for i := 0; i < number; i++ { + tpl := &core.RelationTuple{ + ResourceAndRelation: ONR(resourceName, fmt.Sprintf("%s-%d", resourceName, i), relation), + Subject: ONR(subjectName, fmt.Sprintf("%s-%d", subjectName, i), subjectRelation), + } + tuples = append(tuples, tpl) + } + return tuples +} + +func GenSubjectIdsWithOffset(subjectName string, offset int, number int) []string { + subjectIDs := make([]string, 0, number) + for i := 0; i < number; i++ { + subjectIDs = append(subjectIDs, fmt.Sprintf("%s-%d", subjectName, i+offset)) + } + return subjectIDs +} + +func GenSubjectIds(subjectName string, number int) []string { + return GenSubjectIdsWithOffset(subjectName, 0, number) +} + +func GenTuplesWithCaveat(resourceName string, relation string, subjectName string, subjectID string, caveatName string, context map[string]any, offset int, number int) []*core.RelationTuple { + tuples := make([]*core.RelationTuple, 0, number) + for i := 0; i < number; i++ { + tpl := &core.RelationTuple{ + ResourceAndRelation: ONR(resourceName, fmt.Sprintf("%s-%d", resourceName, i+offset), relation), + Subject: ONR(subjectName, subjectID, "..."), + } + if caveatName != "" { + tpl = tuple.MustWithCaveat(tpl, caveatName, context) + } + tuples = append(tuples, tpl) + } + return tuples +} + +func GenResourceIds(resourceName string, number int) []string { + resourceIDs := make([]string, 0, number) + for i := 0; i < number; i++ { + resourceIDs = append(resourceIDs, fmt.Sprintf("%s-%d", resourceName, i)) + } + return resourceIDs +} diff --git a/pkg/genutil/slicez/chunking_test.go b/pkg/genutil/slicez/chunking_test.go index 78f5366498..30e2c47915 100644 --- a/pkg/genutil/slicez/chunking_test.go +++ b/pkg/genutil/slicez/chunking_test.go @@ -80,3 +80,47 @@ func TestForEachChunkOverflowIncorrect(t *testing.T) { }) } } + +func TestForEachChunkUntil(t *testing.T) { + for _, datasize := range []int{0, 1, 5, 10, 50, 100, 250} { + datasize := datasize + for _, chunksize := range []uint16{1, 2, 3, 5, 10, 50} { + chunksize := chunksize + t.Run(fmt.Sprintf("test-%d-%d", datasize, chunksize), func(t *testing.T) { + var data []int + for i := 0; i < datasize; i++ { + data = append(data, i) + } + + var found []int + ok, err := ForEachChunkUntil(data, chunksize, func(items []int) (bool, error) { + found = append(found, items...) + require.True(t, len(items) <= int(chunksize)) + require.True(t, len(items) > 0) + return true, nil + }) + require.True(t, ok) + require.NoError(t, err) + require.Equal(t, data, found) + }) + } + } +} + +func TestForEachChunkUntilCancels(t *testing.T) { + ok, err := ForEachChunkUntil([]int{1, 2, 3, 4}, 2, func(items []int) (bool, error) { + require.Equal(t, []int{1, 2}, items) + return false, nil + }) + require.False(t, ok) + require.NoError(t, err) +} + +func TestForEachChunkUntilErrors(t *testing.T) { + ok, err := ForEachChunkUntil([]int{1, 2, 3, 4}, 2, func(items []int) (bool, error) { + require.Equal(t, []int{1, 2}, items) + return true, fmt.Errorf("some error") + }) + require.False(t, ok) + require.Error(t, err) +} diff --git a/pkg/proto/dispatch/v1/dispatch.pb.go b/pkg/proto/dispatch/v1/dispatch.pb.go index 6611384ecf..d1f298a8c1 100644 --- a/pkg/proto/dispatch/v1/dispatch.pb.go +++ b/pkg/proto/dispatch/v1/dispatch.pb.go @@ -1186,6 +1186,12 @@ type DispatchLookupSubjectsRequest struct { ResourceRelation *v1.RelationReference `protobuf:"bytes,2,opt,name=resource_relation,json=resourceRelation,proto3" json:"resource_relation,omitempty"` ResourceIds []string `protobuf:"bytes,3,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` SubjectRelation *v1.RelationReference `protobuf:"bytes,4,opt,name=subject_relation,json=subjectRelation,proto3" json:"subject_relation,omitempty"` + // optional_limit, if given, specifies a limit on the number of subjects returned. Note that the number + // returned may be less than this count. + OptionalLimit uint32 `protobuf:"varint,5,opt,name=optional_limit,json=optionalLimit,proto3" json:"optional_limit,omitempty"` + // optional_cursor, if the specified, is the cursor at which to resume returning results. Note + // that lookupsubjects can return duplicates. + OptionalCursor *Cursor `protobuf:"bytes,6,opt,name=optional_cursor,json=optionalCursor,proto3" json:"optional_cursor,omitempty"` } func (x *DispatchLookupSubjectsRequest) Reset() { @@ -1248,6 +1254,20 @@ func (x *DispatchLookupSubjectsRequest) GetSubjectRelation() *v1.RelationReferen return nil } +func (x *DispatchLookupSubjectsRequest) GetOptionalLimit() uint32 { + if x != nil { + return x.OptionalLimit + } + return 0 +} + +func (x *DispatchLookupSubjectsRequest) GetOptionalCursor() *Cursor { + if x != nil { + return x.OptionalCursor + } + return nil +} + type FoundSubject struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1365,6 +1385,7 @@ type DispatchLookupSubjectsResponse struct { FoundSubjectsByResourceId map[string]*FoundSubjects `protobuf:"bytes,1,rep,name=found_subjects_by_resource_id,json=foundSubjectsByResourceId,proto3" json:"found_subjects_by_resource_id,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` Metadata *ResponseMeta `protobuf:"bytes,2,opt,name=metadata,proto3" json:"metadata,omitempty"` + AfterResponseCursor *Cursor `protobuf:"bytes,3,opt,name=after_response_cursor,json=afterResponseCursor,proto3" json:"after_response_cursor,omitempty"` } func (x *DispatchLookupSubjectsResponse) Reset() { @@ -1413,6 +1434,13 @@ func (x *DispatchLookupSubjectsResponse) GetMetadata() *ResponseMeta { return nil } +func (x *DispatchLookupSubjectsResponse) GetAfterResponseCursor() *Cursor { + if x != nil { + return x.AfterResponseCursor + } + return nil +} + type ResolverMeta struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1921,7 +1949,7 @@ var file_dispatch_v1_dispatch_proto_rawDesc = []byte{ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x52, 0x13, 0x61, 0x66, 0x74, 0x65, 0x72, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0xa7, 0x02, 0x0a, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0x8c, 0x03, 0x0a, 0x1d, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, @@ -1940,158 +1968,169 @@ var file_dispatch_v1_dispatch_proto_rawDesc = []byte{ 0x1a, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xbd, 0x01, 0x0a, 0x0c, 0x46, 0x6f, 0x75, 0x6e, 0x64, - 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x75, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x75, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x49, 0x64, 0x12, 0x46, 0x0a, 0x11, 0x63, 0x61, 0x76, 0x65, 0x61, 0x74, - 0x5f, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x61, 0x76, 0x65, - 0x61, 0x74, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x10, 0x63, 0x61, - 0x76, 0x65, 0x61, 0x74, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, - 0x0a, 0x11, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x73, 0x75, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x64, 0x69, 0x73, 0x70, - 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x52, 0x10, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x53, 0x75, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x22, 0x51, 0x0a, 0x0d, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, - 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x40, 0x0a, 0x0e, 0x66, 0x6f, 0x75, 0x6e, 0x64, - 0x5f, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x19, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, - 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x0d, 0x66, 0x6f, 0x75, 0x6e, - 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x22, 0xd0, 0x02, 0x0a, 0x1e, 0x44, 0x69, - 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x8c, 0x01, 0x0a, - 0x1d, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x5f, - 0x62, 0x79, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x4a, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, - 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, + 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x61, 0x6c, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, + 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x3c, 0x0a, + 0x0f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x52, 0x0e, 0x6f, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0xbd, 0x01, 0x0a, 0x0c, + 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x49, 0x64, 0x12, 0x46, 0x0a, 0x11, 0x63, + 0x61, 0x76, 0x65, 0x61, 0x74, 0x5f, 0x65, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x61, 0x76, 0x65, 0x61, 0x74, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x10, 0x63, 0x61, 0x76, 0x65, 0x61, 0x74, 0x45, 0x78, 0x70, 0x72, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x11, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, + 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x75, + 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x10, 0x65, 0x78, 0x63, 0x6c, 0x75, + 0x64, 0x65, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x22, 0x51, 0x0a, 0x0d, 0x46, + 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x40, 0x0a, 0x0e, + 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, + 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, + 0x0d, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x22, 0x99, + 0x03, 0x0a, 0x1e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x2e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x42, - 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x19, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x42, - 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x6d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, - 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x1a, 0x68, 0x0a, 0x1e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x73, 0x42, 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, - 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xc1, 0x01, 0x0a, - 0x0c, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x29, 0x0a, - 0x0b, 0x61, 0x74, 0x5f, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x72, 0x03, 0x28, 0x80, 0x08, 0x52, 0x0a, 0x61, 0x74, - 0x52, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x0f, 0x64, 0x65, 0x70, 0x74, - 0x68, 0x5f, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0d, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x2a, 0x02, 0x20, 0x00, 0x52, 0x0e, 0x64, 0x65, 0x70, 0x74, - 0x68, 0x52, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x0a, 0x0a, 0x72, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, - 0x18, 0x01, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x31, 0x0a, - 0x0f, 0x74, 0x72, 0x61, 0x76, 0x65, 0x72, 0x73, 0x61, 0x6c, 0x5f, 0x62, 0x6c, 0x6f, 0x6f, 0x6d, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x7a, 0x03, 0x18, 0x80, 0x08, - 0x52, 0x0e, 0x74, 0x72, 0x61, 0x76, 0x65, 0x72, 0x73, 0x61, 0x6c, 0x42, 0x6c, 0x6f, 0x6f, 0x6d, - 0x22, 0xda, 0x01, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, - 0x61, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x61, - 0x74, 0x63, 0x68, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x70, 0x74, - 0x68, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, - 0x52, 0x0d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x12, - 0x32, 0x0a, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, - 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, - 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x6f, - 0x75, 0x6e, 0x74, 0x12, 0x3c, 0x0a, 0x0a, 0x64, 0x65, 0x62, 0x75, 0x67, 0x5f, 0x69, 0x6e, 0x66, - 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, - 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x72, - 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, - 0x6f, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x46, 0x0a, - 0x10, 0x44, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x32, 0x0a, 0x05, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1c, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, - 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x52, 0x05, - 0x63, 0x68, 0x65, 0x63, 0x6b, 0x22, 0xaf, 0x04, 0x0a, 0x0f, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, - 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x12, 0x3b, 0x0a, 0x07, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x64, 0x69, 0x73, - 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, - 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x72, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x5f, 0x0a, 0x16, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x29, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, + 0x65, 0x12, 0x8c, 0x01, 0x0a, 0x1d, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x73, 0x75, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x73, 0x5f, 0x62, 0x79, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x4a, 0x2e, 0x64, 0x69, 0x73, 0x70, + 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, + 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x73, 0x42, 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x19, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x73, 0x42, 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, + 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, + 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x47, 0x0a, 0x15, 0x61, 0x66, 0x74, 0x65, 0x72, + 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x52, 0x13, 0x61, 0x66, 0x74, + 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, + 0x1a, 0x68, 0x0a, 0x1e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x73, 0x42, 0x79, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, + 0x31, 0x2e, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0c, 0x52, + 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x29, 0x0a, 0x0b, 0x61, + 0x74, 0x5f, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x08, 0xfa, 0x42, 0x05, 0x72, 0x03, 0x28, 0x80, 0x08, 0x52, 0x0a, 0x61, 0x74, 0x52, 0x65, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x0f, 0x64, 0x65, 0x70, 0x74, 0x68, 0x5f, + 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x42, + 0x07, 0xfa, 0x42, 0x04, 0x2a, 0x02, 0x20, 0x00, 0x52, 0x0e, 0x64, 0x65, 0x70, 0x74, 0x68, 0x52, + 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x0f, 0x74, + 0x72, 0x61, 0x76, 0x65, 0x72, 0x73, 0x61, 0x6c, 0x5f, 0x62, 0x6c, 0x6f, 0x6f, 0x6d, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0c, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x7a, 0x03, 0x18, 0x80, 0x08, 0x52, 0x0e, + 0x74, 0x72, 0x61, 0x76, 0x65, 0x72, 0x73, 0x61, 0x6c, 0x42, 0x6c, 0x6f, 0x6f, 0x6d, 0x22, 0xda, + 0x01, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x12, + 0x25, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x70, 0x74, 0x68, 0x5f, + 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, + 0x64, 0x65, 0x70, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x12, 0x32, 0x0a, + 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, + 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x64, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x12, 0x3c, 0x0a, 0x0a, 0x64, 0x65, 0x62, 0x75, 0x67, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, + 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x4a, + 0x04, 0x08, 0x04, 0x10, 0x05, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x46, 0x0a, 0x10, 0x44, + 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x32, 0x0a, 0x05, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, + 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, + 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x52, 0x05, 0x63, 0x68, + 0x65, 0x63, 0x6b, 0x22, 0xaf, 0x04, 0x0a, 0x0f, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, + 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x12, 0x3b, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, + 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x5f, 0x0a, 0x16, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x29, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, + 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, + 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, + 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x43, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, - 0x72, 0x61, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, - 0x65, 0x52, 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x6c, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x43, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, - 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, - 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, - 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x28, 0x0a, 0x10, - 0x69, 0x73, 0x5f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x73, 0x43, 0x61, 0x63, 0x68, 0x65, 0x64, - 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x3f, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x5f, 0x70, 0x72, - 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x64, - 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x50, - 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x73, 0x12, 0x35, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, - 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x5c, - 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x36, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x20, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, - 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x39, 0x0a, 0x0c, - 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, - 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x52, 0x45, 0x4c, - 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x45, 0x52, 0x4d, 0x49, - 0x53, 0x53, 0x49, 0x4f, 0x4e, 0x10, 0x02, 0x32, 0xbd, 0x04, 0x0a, 0x0f, 0x44, 0x69, 0x73, 0x70, - 0x61, 0x74, 0x63, 0x68, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x58, 0x0a, 0x0d, 0x44, - 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x21, 0x2e, 0x64, - 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, - 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x22, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, - 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5b, 0x0a, 0x0e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, - 0x68, 0x45, 0x78, 0x70, 0x61, 0x6e, 0x64, 0x12, 0x22, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, - 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, - 0x70, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x64, 0x69, - 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, - 0x63, 0x68, 0x45, 0x78, 0x70, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x00, 0x12, 0x81, 0x01, 0x0a, 0x1a, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, - 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x73, 0x12, 0x2e, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, - 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c, - 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x2f, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, - 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c, - 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x78, 0x0a, 0x17, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, - 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x73, 0x12, 0x2b, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, - 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, - 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, - 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, - 0x12, 0x75, 0x0a, 0x16, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, - 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x2a, 0x2e, 0x64, 0x69, 0x73, + 0x72, 0x61, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x69, 0x73, + 0x5f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x73, 0x43, 0x61, 0x63, 0x68, 0x65, 0x64, 0x52, 0x65, + 0x73, 0x75, 0x6c, 0x74, 0x12, 0x3f, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x5f, 0x70, 0x72, 0x6f, 0x62, + 0x6c, 0x65, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x64, 0x69, 0x73, + 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, + 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x50, 0x72, 0x6f, + 0x62, 0x6c, 0x65, 0x6d, 0x73, 0x12, 0x35, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x5c, 0x0a, 0x0c, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x36, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, + 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x39, 0x0a, 0x0c, 0x52, 0x65, + 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, + 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x52, 0x45, 0x4c, 0x41, 0x54, + 0x49, 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x45, 0x52, 0x4d, 0x49, 0x53, 0x53, + 0x49, 0x4f, 0x4e, 0x10, 0x02, 0x32, 0xbd, 0x04, 0x0a, 0x0f, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, + 0x63, 0x68, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x58, 0x0a, 0x0d, 0x44, 0x69, 0x73, + 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x21, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, - 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, - 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, - 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42, 0xaa, 0x01, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x2e, - 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x42, 0x0d, 0x44, 0x69, 0x73, - 0x70, 0x61, 0x74, 0x63, 0x68, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x3b, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x65, 0x64, - 0x2f, 0x73, 0x70, 0x69, 0x63, 0x65, 0x64, 0x62, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2f, 0x76, 0x31, 0x3b, 0x64, - 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x44, 0x58, 0x58, 0xaa, - 0x02, 0x0b, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0b, - 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x17, 0x44, 0x69, - 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0c, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, - 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, + 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, + 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x12, 0x5b, 0x0a, 0x0e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, + 0x78, 0x70, 0x61, 0x6e, 0x64, 0x12, 0x22, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, + 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x70, 0x61, + 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x64, 0x69, 0x73, 0x70, + 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, + 0x45, 0x78, 0x70, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, + 0x12, 0x81, 0x01, 0x0a, 0x1a, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, + 0x63, 0x68, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, + 0x2e, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, + 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c, 0x65, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x2f, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, + 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c, 0x65, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x30, 0x01, 0x12, 0x78, 0x0a, 0x17, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, + 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, + 0x2b, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, + 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x64, + 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, + 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x75, + 0x0a, 0x16, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, + 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x2a, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, + 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, + 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, + 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, + 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x30, 0x01, 0x42, 0xaa, 0x01, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x2e, 0x64, 0x69, + 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x42, 0x0d, 0x44, 0x69, 0x73, 0x70, 0x61, + 0x74, 0x63, 0x68, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x65, 0x64, 0x2f, 0x73, + 0x70, 0x69, 0x63, 0x65, 0x64, 0x62, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2f, 0x76, 0x31, 0x3b, 0x64, 0x69, 0x73, + 0x70, 0x61, 0x74, 0x63, 0x68, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x44, 0x58, 0x58, 0xaa, 0x02, 0x0b, + 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0b, 0x44, 0x69, + 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x17, 0x44, 0x69, 0x73, 0x70, + 0x61, 0x74, 0x63, 0x68, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0xea, 0x02, 0x0c, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x3a, 0x3a, + 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2181,36 +2220,38 @@ var file_dispatch_v1_dispatch_proto_depIdxs = []int32{ 23, // 31: dispatch.v1.DispatchLookupSubjectsRequest.metadata:type_name -> dispatch.v1.ResolverMeta 30, // 32: dispatch.v1.DispatchLookupSubjectsRequest.resource_relation:type_name -> core.v1.RelationReference 30, // 33: dispatch.v1.DispatchLookupSubjectsRequest.subject_relation:type_name -> core.v1.RelationReference - 32, // 34: dispatch.v1.FoundSubject.caveat_expression:type_name -> core.v1.CaveatExpression - 20, // 35: dispatch.v1.FoundSubject.excluded_subjects:type_name -> dispatch.v1.FoundSubject - 20, // 36: dispatch.v1.FoundSubjects.found_subjects:type_name -> dispatch.v1.FoundSubject - 28, // 37: dispatch.v1.DispatchLookupSubjectsResponse.found_subjects_by_resource_id:type_name -> dispatch.v1.DispatchLookupSubjectsResponse.FoundSubjectsByResourceIdEntry - 24, // 38: dispatch.v1.DispatchLookupSubjectsResponse.metadata:type_name -> dispatch.v1.ResponseMeta - 25, // 39: dispatch.v1.ResponseMeta.debug_info:type_name -> dispatch.v1.DebugInformation - 26, // 40: dispatch.v1.DebugInformation.check:type_name -> dispatch.v1.CheckDebugTrace - 7, // 41: dispatch.v1.CheckDebugTrace.request:type_name -> dispatch.v1.DispatchCheckRequest - 6, // 42: dispatch.v1.CheckDebugTrace.resource_relation_type:type_name -> dispatch.v1.CheckDebugTrace.RelationType - 29, // 43: dispatch.v1.CheckDebugTrace.results:type_name -> dispatch.v1.CheckDebugTrace.ResultsEntry - 26, // 44: dispatch.v1.CheckDebugTrace.sub_problems:type_name -> dispatch.v1.CheckDebugTrace - 35, // 45: dispatch.v1.CheckDebugTrace.duration:type_name -> google.protobuf.Duration - 9, // 46: dispatch.v1.DispatchCheckResponse.ResultsByResourceIdEntry.value:type_name -> dispatch.v1.ResourceCheckResult - 21, // 47: dispatch.v1.DispatchLookupSubjectsResponse.FoundSubjectsByResourceIdEntry.value:type_name -> dispatch.v1.FoundSubjects - 9, // 48: dispatch.v1.CheckDebugTrace.ResultsEntry.value:type_name -> dispatch.v1.ResourceCheckResult - 7, // 49: dispatch.v1.DispatchService.DispatchCheck:input_type -> dispatch.v1.DispatchCheckRequest - 10, // 50: dispatch.v1.DispatchService.DispatchExpand:input_type -> dispatch.v1.DispatchExpandRequest - 13, // 51: dispatch.v1.DispatchService.DispatchReachableResources:input_type -> dispatch.v1.DispatchReachableResourcesRequest - 16, // 52: dispatch.v1.DispatchService.DispatchLookupResources:input_type -> dispatch.v1.DispatchLookupResourcesRequest - 19, // 53: dispatch.v1.DispatchService.DispatchLookupSubjects:input_type -> dispatch.v1.DispatchLookupSubjectsRequest - 8, // 54: dispatch.v1.DispatchService.DispatchCheck:output_type -> dispatch.v1.DispatchCheckResponse - 11, // 55: dispatch.v1.DispatchService.DispatchExpand:output_type -> dispatch.v1.DispatchExpandResponse - 15, // 56: dispatch.v1.DispatchService.DispatchReachableResources:output_type -> dispatch.v1.DispatchReachableResourcesResponse - 18, // 57: dispatch.v1.DispatchService.DispatchLookupResources:output_type -> dispatch.v1.DispatchLookupResourcesResponse - 22, // 58: dispatch.v1.DispatchService.DispatchLookupSubjects:output_type -> dispatch.v1.DispatchLookupSubjectsResponse - 54, // [54:59] is the sub-list for method output_type - 49, // [49:54] is the sub-list for method input_type - 49, // [49:49] is the sub-list for extension type_name - 49, // [49:49] is the sub-list for extension extendee - 0, // [0:49] is the sub-list for field type_name + 12, // 34: dispatch.v1.DispatchLookupSubjectsRequest.optional_cursor:type_name -> dispatch.v1.Cursor + 32, // 35: dispatch.v1.FoundSubject.caveat_expression:type_name -> core.v1.CaveatExpression + 20, // 36: dispatch.v1.FoundSubject.excluded_subjects:type_name -> dispatch.v1.FoundSubject + 20, // 37: dispatch.v1.FoundSubjects.found_subjects:type_name -> dispatch.v1.FoundSubject + 28, // 38: dispatch.v1.DispatchLookupSubjectsResponse.found_subjects_by_resource_id:type_name -> dispatch.v1.DispatchLookupSubjectsResponse.FoundSubjectsByResourceIdEntry + 24, // 39: dispatch.v1.DispatchLookupSubjectsResponse.metadata:type_name -> dispatch.v1.ResponseMeta + 12, // 40: dispatch.v1.DispatchLookupSubjectsResponse.after_response_cursor:type_name -> dispatch.v1.Cursor + 25, // 41: dispatch.v1.ResponseMeta.debug_info:type_name -> dispatch.v1.DebugInformation + 26, // 42: dispatch.v1.DebugInformation.check:type_name -> dispatch.v1.CheckDebugTrace + 7, // 43: dispatch.v1.CheckDebugTrace.request:type_name -> dispatch.v1.DispatchCheckRequest + 6, // 44: dispatch.v1.CheckDebugTrace.resource_relation_type:type_name -> dispatch.v1.CheckDebugTrace.RelationType + 29, // 45: dispatch.v1.CheckDebugTrace.results:type_name -> dispatch.v1.CheckDebugTrace.ResultsEntry + 26, // 46: dispatch.v1.CheckDebugTrace.sub_problems:type_name -> dispatch.v1.CheckDebugTrace + 35, // 47: dispatch.v1.CheckDebugTrace.duration:type_name -> google.protobuf.Duration + 9, // 48: dispatch.v1.DispatchCheckResponse.ResultsByResourceIdEntry.value:type_name -> dispatch.v1.ResourceCheckResult + 21, // 49: dispatch.v1.DispatchLookupSubjectsResponse.FoundSubjectsByResourceIdEntry.value:type_name -> dispatch.v1.FoundSubjects + 9, // 50: dispatch.v1.CheckDebugTrace.ResultsEntry.value:type_name -> dispatch.v1.ResourceCheckResult + 7, // 51: dispatch.v1.DispatchService.DispatchCheck:input_type -> dispatch.v1.DispatchCheckRequest + 10, // 52: dispatch.v1.DispatchService.DispatchExpand:input_type -> dispatch.v1.DispatchExpandRequest + 13, // 53: dispatch.v1.DispatchService.DispatchReachableResources:input_type -> dispatch.v1.DispatchReachableResourcesRequest + 16, // 54: dispatch.v1.DispatchService.DispatchLookupResources:input_type -> dispatch.v1.DispatchLookupResourcesRequest + 19, // 55: dispatch.v1.DispatchService.DispatchLookupSubjects:input_type -> dispatch.v1.DispatchLookupSubjectsRequest + 8, // 56: dispatch.v1.DispatchService.DispatchCheck:output_type -> dispatch.v1.DispatchCheckResponse + 11, // 57: dispatch.v1.DispatchService.DispatchExpand:output_type -> dispatch.v1.DispatchExpandResponse + 15, // 58: dispatch.v1.DispatchService.DispatchReachableResources:output_type -> dispatch.v1.DispatchReachableResourcesResponse + 18, // 59: dispatch.v1.DispatchService.DispatchLookupResources:output_type -> dispatch.v1.DispatchLookupResourcesResponse + 22, // 60: dispatch.v1.DispatchService.DispatchLookupSubjects:output_type -> dispatch.v1.DispatchLookupSubjectsResponse + 56, // [56:61] is the sub-list for method output_type + 51, // [51:56] is the sub-list for method input_type + 51, // [51:51] is the sub-list for extension type_name + 51, // [51:51] is the sub-list for extension extendee + 0, // [0:51] is the sub-list for field type_name } func init() { file_dispatch_v1_dispatch_proto_init() } diff --git a/pkg/proto/dispatch/v1/dispatch.pb.validate.go b/pkg/proto/dispatch/v1/dispatch.pb.validate.go index bfe4243241..66b531c656 100644 --- a/pkg/proto/dispatch/v1/dispatch.pb.validate.go +++ b/pkg/proto/dispatch/v1/dispatch.pb.validate.go @@ -2288,6 +2288,37 @@ func (m *DispatchLookupSubjectsRequest) validate(all bool) error { } } + // no validation rules for OptionalLimit + + if all { + switch v := interface{}(m.GetOptionalCursor()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, DispatchLookupSubjectsRequestValidationError{ + field: "OptionalCursor", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, DispatchLookupSubjectsRequestValidationError{ + field: "OptionalCursor", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetOptionalCursor()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return DispatchLookupSubjectsRequestValidationError{ + field: "OptionalCursor", + reason: "embedded message failed validation", + cause: err, + } + } + } + if len(errors) > 0 { return DispatchLookupSubjectsRequestMultiError(errors) } @@ -2764,6 +2795,35 @@ func (m *DispatchLookupSubjectsResponse) validate(all bool) error { } } + if all { + switch v := interface{}(m.GetAfterResponseCursor()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, DispatchLookupSubjectsResponseValidationError{ + field: "AfterResponseCursor", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, DispatchLookupSubjectsResponseValidationError{ + field: "AfterResponseCursor", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetAfterResponseCursor()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return DispatchLookupSubjectsResponseValidationError{ + field: "AfterResponseCursor", + reason: "embedded message failed validation", + cause: err, + } + } + } + if len(errors) > 0 { return DispatchLookupSubjectsResponseMultiError(errors) } diff --git a/pkg/proto/dispatch/v1/dispatch_vtproto.pb.go b/pkg/proto/dispatch/v1/dispatch_vtproto.pb.go index 78aac4308f..aaa0c54b65 100644 --- a/pkg/proto/dispatch/v1/dispatch_vtproto.pb.go +++ b/pkg/proto/dispatch/v1/dispatch_vtproto.pb.go @@ -350,6 +350,8 @@ func (m *DispatchLookupSubjectsRequest) CloneVT() *DispatchLookupSubjectsRequest } r := new(DispatchLookupSubjectsRequest) r.Metadata = m.Metadata.CloneVT() + r.OptionalLimit = m.OptionalLimit + r.OptionalCursor = m.OptionalCursor.CloneVT() if rhs := m.ResourceRelation; rhs != nil { if vtpb, ok := interface{}(rhs).(interface{ CloneVT() *v1.RelationReference }); ok { r.ResourceRelation = vtpb.CloneVT() @@ -440,6 +442,7 @@ func (m *DispatchLookupSubjectsResponse) CloneVT() *DispatchLookupSubjectsRespon } r := new(DispatchLookupSubjectsResponse) r.Metadata = m.Metadata.CloneVT() + r.AfterResponseCursor = m.AfterResponseCursor.CloneVT() if rhs := m.FoundSubjectsByResourceId; rhs != nil { tmpContainer := make(map[string]*FoundSubjects, len(rhs)) for k, v := range rhs { @@ -1014,6 +1017,12 @@ func (this *DispatchLookupSubjectsRequest) EqualVT(that *DispatchLookupSubjectsR } else if !proto.Equal(this.SubjectRelation, that.SubjectRelation) { return false } + if this.OptionalLimit != that.OptionalLimit { + return false + } + if !this.OptionalCursor.EqualVT(that.OptionalCursor) { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -1131,6 +1140,9 @@ func (this *DispatchLookupSubjectsResponse) EqualVT(that *DispatchLookupSubjects if !this.Metadata.EqualVT(that.Metadata) { return false } + if !this.AfterResponseCursor.EqualVT(that.AfterResponseCursor) { + return false + } return string(this.unknownFields) == string(that.unknownFields) } @@ -2190,6 +2202,21 @@ func (m *DispatchLookupSubjectsRequest) MarshalToSizedBufferVT(dAtA []byte) (int i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if m.OptionalCursor != nil { + size, err := m.OptionalCursor.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x32 + } + if m.OptionalLimit != 0 { + i = protohelpers.EncodeVarint(dAtA, i, uint64(m.OptionalLimit)) + i-- + dAtA[i] = 0x28 + } if m.SubjectRelation != nil { if vtmsg, ok := interface{}(m.SubjectRelation).(interface { MarshalToSizedBufferVT([]byte) (int, error) @@ -2405,6 +2432,16 @@ func (m *DispatchLookupSubjectsResponse) MarshalToSizedBufferVT(dAtA []byte) (in i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if m.AfterResponseCursor != nil { + size, err := m.AfterResponseCursor.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } if m.Metadata != nil { size, err := m.Metadata.MarshalToSizedBufferVT(dAtA[:i]) if err != nil { @@ -3092,6 +3129,13 @@ func (m *DispatchLookupSubjectsRequest) SizeVT() (n int) { } n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + if m.OptionalLimit != 0 { + n += 1 + protohelpers.SizeOfVarint(uint64(m.OptionalLimit)) + } + if m.OptionalCursor != nil { + l = m.OptionalCursor.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -3165,6 +3209,10 @@ func (m *DispatchLookupSubjectsResponse) SizeVT() (n int) { l = m.Metadata.SizeVT() n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + if m.AfterResponseCursor != nil { + l = m.AfterResponseCursor.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -5563,6 +5611,61 @@ func (m *DispatchLookupSubjectsRequest) UnmarshalVT(dAtA []byte) error { } } iNdEx = postIndex + case 5: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field OptionalLimit", wireType) + } + m.OptionalLimit = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.OptionalLimit |= uint32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 6: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field OptionalCursor", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.OptionalCursor == nil { + m.OptionalCursor = &Cursor{} + } + if err := m.OptionalCursor.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) @@ -6025,6 +6128,42 @@ func (m *DispatchLookupSubjectsResponse) UnmarshalVT(dAtA []byte) error { return err } iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AfterResponseCursor", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.AfterResponseCursor == nil { + m.AfterResponseCursor = &Cursor{} + } + if err := m.AfterResponseCursor.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/pkg/typesystem/typesystem.go b/pkg/typesystem/typesystem.go index a003feacc6..521df99f1e 100644 --- a/pkg/typesystem/typesystem.go +++ b/pkg/typesystem/typesystem.go @@ -138,6 +138,16 @@ func (nts *TypeSystem) MustGetRelation(relationName string) *core.Relation { return rel } +// GetRelationOrError returns the relation with the givne name defined on the namespace, or RelationNotFoundErr if +// not found. +func (nts *TypeSystem) GetRelationOrError(relationName string) (*core.Relation, error) { + relation, ok := nts.relationMap[relationName] + if !ok { + return nil, NewRelationNotFoundErr(nts.nsDef.Name, relationName) + } + return relation, nil +} + // IsPermission returns true if the namespace has the given relation defined and it is // a permission. func (nts *TypeSystem) IsPermission(relationName string) bool { @@ -281,6 +291,27 @@ func (nts *TypeSystem) AllowedSubjectRelations(sourceRelationName string) ([]*co return filtered, nil } +// HasIndirectSubjects returns true if and only if there exists at least one non-ellipsis (i.e. indirect) subject +// allowed on the specified relation. +func (nts *TypeSystem) HasIndirectSubjects(sourceRelationName string) (bool, error) { + allowedRelations, err := nts.AllowedDirectRelationsAndWildcards(sourceRelationName) + if err != nil { + return false, asTypeError(err) + } + + for _, allowedRelation := range allowedRelations { + if allowedRelation.GetPublicWildcard() != nil { + continue + } + + if allowedRelation.GetRelation() != tuple.Ellipsis { + return true, nil + } + } + + return false, nil +} + // WildcardTypeReference represents a relation that references a wildcard type. type WildcardTypeReference struct { // ReferencingRelation is the relation referencing the wildcard type. diff --git a/pkg/typesystem/typesystem_test.go b/pkg/typesystem/typesystem_test.go index 17e3f1e8e8..81f6f862b4 100644 --- a/pkg/typesystem/typesystem_test.go +++ b/pkg/typesystem/typesystem_test.go @@ -458,6 +458,21 @@ func TestTypeSystemAccessors(t *testing.T) { require.False(t, vts.IsPermission("somenonpermission")) }, "resource": func(t *testing.T, vts *ValidatedNamespaceTypeSystem) { + t.Run("GetRelationOrError", func(t *testing.T) { + require.NotNil(t, noError(vts.GetRelationOrError("editor"))) + require.NotNil(t, noError(vts.GetRelationOrError("viewer"))) + + _, err := vts.GetRelationOrError("someunknownrel") + require.Error(t, err) + require.ErrorAs(t, err, &ErrRelationNotFound{}) + require.ErrorContains(t, err, "relation/permission `someunknownrel` not found") + }) + + t.Run("HasIndirectSubjects", func(t *testing.T) { + require.False(t, noError(vts.HasIndirectSubjects("editor"))) + require.False(t, noError(vts.HasIndirectSubjects("viewer"))) + }) + t.Run("IsPermission", func(t *testing.T) { require.False(t, vts.IsPermission("somenonpermission")) @@ -544,6 +559,11 @@ func TestTypeSystemAccessors(t *testing.T) { require.True(t, vts.IsPermission("view")) }) + t.Run("HasIndirectSubjects", func(t *testing.T) { + require.False(t, noError(vts.HasIndirectSubjects("editor"))) + require.False(t, noError(vts.HasIndirectSubjects("viewer"))) + }) + t.Run("IsAllowedPublicNamespace", func(t *testing.T) { require.Equal(t, PublicSubjectNotAllowed, noError(vts.IsAllowedPublicNamespace("editor", "user"))) require.Equal(t, PublicSubjectAllowed, noError(vts.IsAllowedPublicNamespace("viewer", "user"))) @@ -603,6 +623,10 @@ func TestTypeSystemAccessors(t *testing.T) { require.False(t, vts.IsPermission("member")) }) + t.Run("HasIndirectSubjects", func(t *testing.T) { + require.True(t, noError(vts.HasIndirectSubjects("member"))) + }) + t.Run("IsAllowedPublicNamespace", func(t *testing.T) { require.Equal(t, PublicSubjectNotAllowed, noError(vts.IsAllowedPublicNamespace("member", "user"))) }) @@ -664,6 +688,12 @@ func TestTypeSystemAccessors(t *testing.T) { require.False(t, vts.IsPermission("onlycaveated")) }) + t.Run("HasIndirectSubjects", func(t *testing.T) { + require.False(t, noError(vts.HasIndirectSubjects("editor"))) + require.False(t, noError(vts.HasIndirectSubjects("viewer"))) + require.False(t, noError(vts.HasIndirectSubjects("onlycaveated"))) + }) + t.Run("IsAllowedPublicNamespace", func(t *testing.T) { require.Equal(t, PublicSubjectNotAllowed, noError(vts.IsAllowedPublicNamespace("editor", "user"))) require.Equal(t, PublicSubjectNotAllowed, noError(vts.IsAllowedPublicNamespace("viewer", "user"))) diff --git a/proto/internal/dispatch/v1/dispatch.proto b/proto/internal/dispatch/v1/dispatch.proto index 92c78a1a2e..05b8f363f2 100644 --- a/proto/internal/dispatch/v1/dispatch.proto +++ b/proto/internal/dispatch/v1/dispatch.proto @@ -163,7 +163,16 @@ message DispatchLookupSubjectsRequest { core.v1.RelationReference resource_relation = 2 [(validate.rules).message.required = true]; repeated string resource_ids = 3; - core.v1.RelationReference subject_relation = 4 [(validate.rules).message.required = true]; + core.v1.RelationReference subject_relation = 4 + [ (validate.rules).message.required = true ]; + + // optional_limit, if given, specifies a limit on the number of subjects returned. Note that the number + // returned may be less than this count. + uint32 optional_limit = 5; + + // optional_cursor, if the specified, is the cursor at which to resume returning results. Note + // that lookupsubjects can return duplicates. + Cursor optional_cursor = 6; } message FoundSubject { @@ -179,6 +188,7 @@ message FoundSubjects { message DispatchLookupSubjectsResponse { map found_subjects_by_resource_id = 1; ResponseMeta metadata = 2; + Cursor after_response_cursor = 3; } message ResolverMeta {