From da48a6b77422c247e58aff569ca382a490d93bf6 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 24 May 2023 10:31:25 -0400 Subject: [PATCH] Add support for basic cursors and limits to LookupSubjects This change supports a limit (called the "concrete limit") on LookupSubjects and will filter concrete subjects based on the returned cursor. This change does *not* filter intermediate lookups, which will be done in a followup PR. --- e2e/go.mod | 2 +- e2e/go.sum | 4 +- go.mod | 4 +- go.sum | 4 +- internal/datasets/basesubjectset.go | 19 + internal/datasets/subjectset_test.go | 7 + internal/datasets/subjectsetbyresourceid.go | 9 + .../datasets/subjectsetbyresourceid_test.go | 3 + internal/datasets/subjectsetbytype.go | 17 + .../dispatch/graph/lookupsubjects_test.go | 713 +++++++++++++ internal/graph/cursors.go | 57 +- internal/graph/cursors_test.go | 45 +- internal/graph/lookupsubjects.go | 969 +++++++++++++++--- internal/graph/lookupsubjects_test.go | 218 ++++ internal/namespace/typesystem.go | 10 + internal/namespace/typesystem_test.go | 10 + .../integrationtesting/consistency_test.go | 295 +++--- .../consistencytestutil/servicetester.go | 18 +- internal/services/v1/hash.go | 11 + internal/services/v1/hash_test.go | 184 ++++ internal/services/v1/permissions.go | 181 ++-- internal/services/v1/permissions_test.go | 130 +++ pkg/proto/dispatch/v1/dispatch.pb.go | 381 ++++--- pkg/proto/dispatch/v1/dispatch.pb.validate.go | 60 ++ pkg/proto/dispatch/v1/dispatch_vtproto.pb.go | 143 ++- pkg/util/chunking.go | 16 +- pkg/util/chunking_test.go | 44 + proto/internal/dispatch/v1/dispatch.proto | 9 + 28 files changed, 2936 insertions(+), 627 deletions(-) create mode 100644 internal/graph/lookupsubjects_test.go diff --git a/e2e/go.mod b/e2e/go.mod index bb49531c63..68b3a0772e 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -3,7 +3,7 @@ module github.com/authzed/spicedb/e2e go 1.19 require ( - github.com/authzed/authzed-go v0.8.1-0.20230601141426-d411c3f66256 + github.com/authzed/authzed-go v0.8.1-0.20230601170210-d97cfb410277 github.com/authzed/grpcutil v0.0.0-20230509155820-7a6fedb71dbc github.com/authzed/spicedb v1.21.0 github.com/brianvoe/gofakeit/v6 v6.15.0 diff --git a/e2e/go.sum b/e2e/go.sum index a957f1c31e..caf73c0573 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -394,8 +394,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrGr5AyrNMXuA0gves= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= -github.com/authzed/authzed-go v0.8.1-0.20230601141426-d411c3f66256 h1:fu0vsI/+dxpQpdWihrI219piaL2GbXOTrLAMM8ZjanM= -github.com/authzed/authzed-go v0.8.1-0.20230601141426-d411c3f66256/go.mod h1:qn4HCG0DQcLybaRePpVFW/Gvgz9UgkvobzyBoA5a49c= +github.com/authzed/authzed-go v0.8.1-0.20230601170210-d97cfb410277 h1:NKwiHNkS5UYymzADmrrJ/dfMRrN4kKpWpP9Fwx9Nids= +github.com/authzed/authzed-go v0.8.1-0.20230601170210-d97cfb410277/go.mod h1:qn4HCG0DQcLybaRePpVFW/Gvgz9UgkvobzyBoA5a49c= github.com/authzed/grpcutil v0.0.0-20230509155820-7a6fedb71dbc h1:FzQP1mljgX15vawHVHlPXQrB6F6wnR/6CftpED+2cvg= github.com/authzed/grpcutil v0.0.0-20230509155820-7a6fedb71dbc/go.mod h1:erPFLN0tntt2V4PAxoUTwqtinm3UhFGzJrcxYKKzGUM= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= diff --git a/go.mod b/go.mod index 57c84b54f8..67d23334ce 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/IBM/pgxpoolprometheus v1.1.1 github.com/Masterminds/squirrel v1.5.3 github.com/agnivade/wasmbrowsertest v0.7.0 - github.com/authzed/authzed-go v0.8.1-0.20230601141426-d411c3f66256 + github.com/authzed/authzed-go v0.8.1-0.20230601170210-d97cfb410277 github.com/authzed/grpcutil v0.0.0-20230509155820-7a6fedb71dbc github.com/aws/aws-sdk-go v1.44.110 github.com/benbjohnson/clock v1.3.3 @@ -63,6 +63,7 @@ require ( github.com/rs/zerolog v1.29.0 github.com/samber/lo v1.38.1 github.com/scylladb/go-set v1.0.2 + github.com/sean-/sysexits v1.0.0 github.com/sercand/kuberesolver/v4 v4.0.0 github.com/shopspring/decimal v1.3.1 github.com/spf13/cobra v1.7.0 @@ -289,7 +290,6 @@ require ( github.com/sanposhiho/wastedassign/v2 v2.0.7 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/usestdlibvars v1.23.0 // indirect - github.com/sean-/sysexits v1.0.0 // indirect github.com/securego/gosec/v2 v2.15.0 // indirect github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500 // indirect github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect diff --git a/go.sum b/go.sum index 99e076e0d0..d39cd615e2 100644 --- a/go.sum +++ b/go.sum @@ -447,8 +447,8 @@ github.com/ashanbrown/forbidigo v1.5.1 h1:WXhzLjOlnuDYPYQo/eFlcFMi8X/kLfvWLYu6CS github.com/ashanbrown/forbidigo v1.5.1/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s= github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI= -github.com/authzed/authzed-go v0.8.1-0.20230601141426-d411c3f66256 h1:fu0vsI/+dxpQpdWihrI219piaL2GbXOTrLAMM8ZjanM= -github.com/authzed/authzed-go v0.8.1-0.20230601141426-d411c3f66256/go.mod h1:qn4HCG0DQcLybaRePpVFW/Gvgz9UgkvobzyBoA5a49c= +github.com/authzed/authzed-go v0.8.1-0.20230601170210-d97cfb410277 h1:NKwiHNkS5UYymzADmrrJ/dfMRrN4kKpWpP9Fwx9Nids= +github.com/authzed/authzed-go v0.8.1-0.20230601170210-d97cfb410277/go.mod h1:qn4HCG0DQcLybaRePpVFW/Gvgz9UgkvobzyBoA5a49c= github.com/authzed/grpcutil v0.0.0-20230509155820-7a6fedb71dbc h1:FzQP1mljgX15vawHVHlPXQrB6F6wnR/6CftpED+2cvg= github.com/authzed/grpcutil v0.0.0-20230509155820-7a6fedb71dbc/go.mod h1:erPFLN0tntt2V4PAxoUTwqtinm3UhFGzJrcxYKKzGUM= github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= diff --git a/internal/datasets/basesubjectset.go b/internal/datasets/basesubjectset.go index a55a937816..107031efa9 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 _, ok := bss.wildcard.get(); ok { + 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 22701d568b..9c3422fa58 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 a85121a544..b1b8264832 100644 --- a/internal/datasets/subjectsetbyresourceid_test.go +++ b/internal/datasets/subjectsetbyresourceid_test.go @@ -43,6 +43,7 @@ func TestSubjectSetByResourceIDBasicOperations(t *testing.T) { sort.Sort(sortFoundSubjects(asMap["seconddoc"].FoundSubjects)) 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()) } type sortFoundSubjects []*v1.FoundSubject diff --git a/internal/datasets/subjectsetbytype.go b/internal/datasets/subjectsetbytype.go index 46c22a43b1..56b37ba20e 100644 --- a/internal/datasets/subjectsetbytype.go +++ b/internal/datasets/subjectsetbytype.go @@ -56,6 +56,23 @@ func (s *SubjectByTypeSet) ForEachType(handler func(rr *core.RelationReference, } } +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/dispatch/graph/lookupsubjects_test.go b/internal/dispatch/graph/lookupsubjects_test.go index c0ce9025f5..57fbbed8fa 100644 --- a/internal/dispatch/graph/lookupsubjects_test.go +++ b/internal/dispatch/graph/lookupsubjects_test.go @@ -26,6 +26,7 @@ import ( var ( caveatexpr = caveats.CaveatExprForTesting caveatAnd = caveats.And + caveatOr = caveats.Or caveatInvert = caveats.Invert ) @@ -685,3 +686,715 @@ func TestCaveatedLookupSubjects(t *testing.T) { }) } } + +func TestCursoredLookupSubjects(t *testing.T) { + testCases := []struct { + name string + schema string + relationships []*corev1.RelationTuple + start *corev1.ObjectAndRelation + target *corev1.RelationReference + expected []*v1.FoundSubject + }{ + { + "simple", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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", + `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"}, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + for _, limit := range []int{0, 1, 2, 5, 100} { + 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/graph/cursors.go b/internal/graph/cursors.go index 7c784163bd..1b617c88fd 100644 --- a/internal/graph/cursors.go +++ b/internal/graph/cursors.go @@ -156,58 +156,6 @@ func (ci cursorInformation) clearIncoming() cursorInformation { } } -type cursorHandler func(c cursorInformation) error - -// withIterableInCursor executes the given handler for each item in the items list, skipping any -// items marked as completed at the head of the cursor and injecting a cursor representing the current -// item. -// -// For example, if items contains 3 items, and the cursor returned was within the handler for item -// index #1, then item index #0 will be skipped on subsequent invocation. -func withIterableInCursor[T any]( - ci cursorInformation, - name string, - items []T, - handler func(ci cursorInformation, item T) error, -) error { - // Check the index for the section in the cursor. If found, we skip any items before that index. - afterIndex, err := ci.integerSectionValue(name) - if err != nil { - return err - } - - isFirstIteration := true - for index, item := range items { - if index < afterIndex { - continue - } - - if ci.limits.hasExhaustedLimit() { - return nil - } - - // Invoke the handler with the current item's index in the outgoing cursor, indicating that - // subsequent invocations should jump right to this item. - currentCursor, err := ci.withOutgoingSection(name, strconv.Itoa(index)) - if err != nil { - return err - } - - if !isFirstIteration { - currentCursor = currentCursor.clearIncoming() - } - - err = handler(currentCursor, item) - if err != nil { - return err - } - - isFirstIteration = false - } - - return nil -} - // withDatastoreCursorInCursor executes the given handler until it returns an empty "next" datastore cursor, // starting at the datastore cursor found in the cursor information (if any). func withDatastoreCursorInCursor( @@ -255,7 +203,10 @@ func withDatastoreCursorInCursor( } } -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/cursors_test.go b/internal/graph/cursors_test.go index a2edc066b6..c1573e9206 100644 --- a/internal/graph/cursors_test.go +++ b/internal/graph/cursors_test.go @@ -2,12 +2,9 @@ package graph import ( "context" - "strconv" "sync" "testing" - "github.com/authzed/spicedb/pkg/tuple" - "github.com/shopspring/decimal" "github.com/stretchr/testify/require" @@ -15,6 +12,7 @@ import ( "github.com/authzed/spicedb/pkg/datastore/options" "github.com/authzed/spicedb/pkg/datastore/revision" v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" + "github.com/authzed/spicedb/pkg/tuple" ) func TestCursorWithWrongRevision(t *testing.T) { @@ -86,47 +84,6 @@ func TestCursorNonIntSection(t *testing.T) { require.Error(t, err) } -func TestWithIterableInCursor(t *testing.T) { - limits, _ := newLimitTracker(context.Background(), 10) - revision := revision.NewFromDecimal(decimal.NewFromInt(1)) - - ci, err := newCursorInformation(&v1.Cursor{ - AtRevision: revision.String(), - Sections: []string{}, - }, revision, limits) - require.NoError(t, err) - - i := 0 - items := []string{"one", "two", "three", "four"} - err = withIterableInCursor(ci, "iter", items, - func(cc cursorInformation, item string) error { - require.Equal(t, items[i], item) - require.Equal(t, []string{"iter", strconv.Itoa(i)}, cc.outgoingCursorSections) - i++ - return nil - }) - - require.NoError(t, err) - require.Equal(t, 4, i) - - ci, err = newCursorInformation(&v1.Cursor{ - AtRevision: revision.String(), - Sections: []string{"iter", "3"}, - }, revision, limits) - require.NoError(t, err) - - j := 3 - err = withIterableInCursor(ci, "iter", items, - func(cc cursorInformation, item string) error { - require.Equal(t, items[j], item) - require.Equal(t, []string{"iter", strconv.Itoa(j)}, cc.outgoingCursorSections) - j++ - return nil - }) - - require.NoError(t, err) -} - func TestWithDatastoreCursorInCursor(t *testing.T) { limits, _ := newLimitTracker(context.Background(), 10) revision := revision.NewFromDecimal(decimal.NewFromInt(1)) diff --git a/internal/graph/lookupsubjects.go b/internal/graph/lookupsubjects.go index d26c996c20..667f7eccb4 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,12 +14,42 @@ 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" 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/util" ) +// afterSubjectIDCursorSection is the name of the section in the cursor holding the subject ID, after which subjects +// should be returned. +const afterSubjectIDCursorSection = "after-subject-id" + +// 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, revision datastore.Revision) (*v1.Cursor, error) { + if afterResponseCursor == nil { + return &v1.Cursor{ + AtRevision: revision.String(), + Sections: []string{afterSubjectIDCursorSection, subjectID}, + }, nil + } + + if len(afterResponseCursor.Sections) != 2 { + return nil, spiceerrors.MustBugf("given an invalid afterResponseCursor (wrong number of sections)") + } + + if afterResponseCursor.Sections[0] != afterSubjectIDCursorSection { + return nil, spiceerrors.MustBugf("given an invalid afterResponseCursor (missing after-subject-id)") + } + + return &v1.Cursor{ + AtRevision: afterResponseCursor.AtRevision, + Sections: []string{afterSubjectIDCursorSection, subjectID}, + }, nil +} + // ValidatedLookupSubjectsRequest represents a request after it has been validated and parsed for internal // consumption. type ValidatedLookupSubjectsRequest struct { @@ -31,6 +62,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 @@ -46,39 +78,114 @@ 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, ctx := newLimitTracker(ctx, req.OptionalLimit) + ci, err := newCursorInformation(req.OptionalCursor, req.Revision, limits) + if err != nil { + return err } + // Run both "branches" in parallel and union together to respect the cursors and limits. + reducer := newLookupSubjectsUnion(stream, ci) + + cancelCtx, checkCancel := context.WithCancel(ctx) + defer checkCancel() + + g, subCtx := errgroup.WithContext(cancelCtx) + g.SetLimit(int(cl.concurrencyLimit)) + + matchingStream := reducer.ForIndex(subCtx, 0) + dispatchingStream := reducer.ForIndex(subCtx, 1) + + // Yield any subjects matching the current resource type. + g.Go(func() error { + branchCI, lCtx := ci.withClonedLimits(subCtx) + return cl.yieldMatchingResources(lCtx, branchCI, req, matchingStream) + }) + + // Yield any subjects found by following the relation. + g.Go(func() error { + branchCI, lCtx := ci.withClonedLimits(subCtx) + return cl.yieldRelationSubjects(lCtx, branchCI, req, dispatchingStream, adjustConcurrencyLimit(cl.concurrencyLimit, 1)) + }) + + if err := g.Wait(); err != nil { + return err + } + + return reducer.CompletedChildOperations() +} + +// yieldMatchingResources yields the current resource IDs iff the resource matches the target +// subject. +func (cl *ConcurrentLookupSubjects) yieldMatchingResources( + ctx 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) + + _, resourceTS, err := namespace.ReadNamespaceAndTypes(ctx, req.ResourceRelation.Namespace, reader) if err != nil { return err } + relation, err := resourceTS.GetRelationOrError(req.ResourceRelation.Relation) + if err != nil { + return err + } + + validatedTS := resourceTS.AsValidated() + 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 { +// 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) { foundSubjects := make(map[string]*v1.FoundSubjects, len(subjectIds)) + afterSubjectID, err := ci.sectionValue(afterSubjectIDCursorSection) + if err != nil { + return nil, err + } + + // If the after subject ID is the wildcard, then no concrete subjects should be returned. + if afterSubjectID == tuple.PublicWildcard { + return nil, nil + } + for _, subjectID := range subjectIds { + if afterSubjectID != "" && subjectID <= afterSubjectID { + continue + } + foundSubjects[subjectID] = &v1.FoundSubjects{ FoundSubjects: []*v1.FoundSubject{ { @@ -88,21 +195,185 @@ 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 *namespace.ValidatedNamespaceTypeSystem, + reader datastore.Reader, + concurrencyLimit uint16, +) error { + reducer := newLookupSubjectsUnion(stream, ci) + + cancelCtx, checkCancel := context.WithCancel(ctx) + defer checkCancel() + + g, subCtx := errgroup.WithContext(cancelCtx) + g.SetLimit(int(concurrencyLimit)) + + directStream := reducer.ForIndex(subCtx, 0) + wildcardStream := reducer.ForIndex(subCtx, 1) + indirectStream := reducer.ForIndex(subCtx, 2) + + // Direct subjects found on the relation. + g.Go(func() error { + branchCI, lCtx := ci.withClonedLimits(subCtx) + return cl.lookupMatchingSubjectsForRelation(lCtx, branchCI, req, directStream, validatedTS, reader) + }) + + // Wildcard on the relation. + g.Go(func() error { + branchCI, lCtx := ci.withClonedLimits(subCtx) + return cl.lookupWildcardSubjectForRelation(lCtx, branchCI, req, wildcardStream, validatedTS, reader) + }) + + // Dispatching over indirect subjects on the relation. + g.Go(func() error { + branchCI, lCtx := ci.withClonedLimits(subCtx) + return cl.dispatchIndirectSubjectsForRelation(lCtx, branchCI, req, indirectStream, validatedTS, reader) + }) + + err := g.Wait() + if err != nil { + return err + } + + return reducer.CompletedChildOperations() +} + +// lookupMatchingSubjectsForRelation finds all directly matching subjects on the request's relation, if applicable. +func (cl *ConcurrentLookupSubjects) lookupMatchingSubjectsForRelation( + ctx context.Context, + ci cursorInformation, req ValidatedLookupSubjectsRequest, stream dispatch.LookupSubjectsStream, - _ *core.Relation, + validatedTS *namespace.ValidatedNamespaceTypeSystem, reader datastore.Reader, ) error { - // TODO(jschorr): use type information to skip subject relations that cannot reach the subject type. + // 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 == namespace.DirectRelationNotValid { + return nil + } + + var afterCursor options.Cursor + afterSubjectID, err := ci.sectionValue(afterSubjectIDCursorSection) + if err != nil { + return err + } + + // 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 ingored 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 *namespace.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 == namespace.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, + validatedTS *namespace.ValidatedNamespaceTypeSystem, + reader datastore.Reader, +) error { + // 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. 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 @@ -110,45 +381,67 @@ func (cl *ConcurrentLookupSubjects) lookupDirectSubjects( defer it.Close() toDispatchByType := datasets.NewSubjectByTypeSet() - foundSubjectsByResourceID := datasets.NewSubjectSetByResourceID() relationshipsBySubjectONR := util.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)) + } + + 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() - return cl.dispatchTo(ctx, req, toDispatchByType, relationshipsBySubjectONR, stream) + for tpl := sit.Next(); tpl != nil; tpl = sit.Next() { + if err := foundSubjectsByResourceID.AddFromRelationship(tpl); err != nil { + return fmt.Errorf("failed to call AddFromRelationship in lookupDirectSubjects: %w", 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, @@ -169,6 +462,7 @@ func (cl *ConcurrentLookupSubjects) lookupViaComputed( return &v1.DispatchLookupSubjectsResponse{ FoundSubjectsByResourceId: result.FoundSubjectsByResourceId, Metadata: addCallToResponseMetadata(result.Metadata), + AfterResponseCursor: result.AfterResponseCursor, }, true, nil }, } @@ -184,11 +478,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, @@ -246,83 +544,162 @@ 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, checkCancel := context.WithCancel(ctx) + defer checkCancel() + + 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 { + subCtx, checkCancel := context.WithCancel(ctx) + defer checkCancel() + + // 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(subCtx, index) + err := reducer.RunUntilSpanned(subCtx, 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 *util.MultiMap[string, *core.RelationTuple], @@ -335,10 +712,7 @@ func (cl *ConcurrentLookupSubjects) dispatchTo( cancelCtx, checkCancel := context.WithCancel(ctx) defer checkCancel() - g, subCtx := errgroup.WithContext(cancelCtx) - g.SetLimit(int(cl.concurrencyLimit)) - - toDispatchByType.ForEachType(func(resourceType *core.RelationReference, foundSubjects datasets.SubjectSet) { + return toDispatchByType.ForEachTypeUntil(func(resourceType *core.RelationReference, foundSubjects datasets.SubjectSet) (bool, error) { slice := foundSubjects.AsSlice() resourceIds := make([]string, 0, len(slice)) for _, foundSubject := range slice { @@ -347,7 +721,7 @@ func (cl *ConcurrentLookupSubjects) dispatchTo( stream := &dispatch.WrappedDispatchStream[*v1.DispatchLookupSubjectsResponse]{ Stream: parentStream, - Ctx: subCtx, + Ctx: cancelCtx, 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. @@ -363,7 +737,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, @@ -408,30 +782,35 @@ 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. - util.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 util.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 }) }) - - return g.Wait() } func combineFoundSubjects(existing *v1.FoundSubjects, toAdd *v1.FoundSubjects) (*v1.FoundSubjects, error) { @@ -448,21 +827,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, } } @@ -494,114 +871,398 @@ 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 = 100 + +// 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 { + branchedCI, lCtx := dbr.firstBranchCi.withClonedLimits(ctx) + return handler(lCtx, branchRunInformation{ci: branchedCI}) + } + + // 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, lctx := newLimitTracker(ctx, dependentBranchReducerReloopLimit) + ci, err := newCursorInformation(startingCursor, dbr.firstBranchCi.revision, limits) + if err != nil { + return err + } + + // Invoke the handler with a modified limits and a cursor starting at the previous call. + if err := handler(lctx, 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(afterSubjectIDCursorSection, firstBranchTerminalSubjectID) + if err != nil { + return -1, err + } + + updatedCursor := updatedCI.responsePartialCursor() + fbci, err := newCursorInformation(updatedCursor, dbr.firstBranchCi.revision, dbr.firstBranchCi.limits) + 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 := util.NewSet[string]() + for _, result := range results { + frc, err := newCursorInformation(result.AfterResponseCursor, ci.revision, ci.limits) + if err != nil { + return "", err + } + + lastSubjectID, err := frc.sectionValue(afterSubjectIDCursorSection) + if err != nil { + return "", err + } + + 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) { + afterSubjectID, err := ci.sectionValue(afterSubjectIDCursorSection) + if err != nil { + return nil, func() {}, err + } - 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) + if subjects == nil { + return nil, func() {}, spiceerrors.MustBugf("nil subjects given to createFilteredAndLimitedResponse") + } + + // Filter down the subjects found by the cursor (if applicable) and the apply a limit. + filteredSubjectIDs := util.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)) + subjectIDsToPublishWithoutWildcard := make([]string, 0, len(sortedSubjectIDs)) + + done := func() {} + for _, subjectID := range sortedSubjectIDs { + // Wildcards are always published, regardless of the limit. + if subjectID == tuple.PublicWildcard { + subjectIDsToPublish = append(subjectIDsToPublish, subjectID) + continue } + + ok, currentDone := ci.limits.prepareForPublishing() + if !ok { + done = currentDone + break + } + + subjectIDsToPublish = append(subjectIDsToPublish, subjectID) + subjectIDsToPublishWithoutWildcard = append(subjectIDsToPublishWithoutWildcard, subjectID) + } + + if len(subjectIDsToPublish) == 0 { + return nil, done, nil } - return lse.parentStream.Publish(&v1.DispatchLookupSubjectsResponse{ - FoundSubjectsByResourceId: foundSubjects.AsMap(), + // 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(subjectIDsToPublishWithoutWildcard) > 0 { + cursorSubjectId = subjectIDsToPublishWithoutWildcard[len(subjectIDsToPublishWithoutWildcard)-1] + } + + updatedCI, err := ci.withOutgoingSection(afterSubjectIDCursorSection, cursorSubjectId) + if err != nil { + return nil, func() {}, 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 := util.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..f07f85e3e5 --- /dev/null +++ b/internal/graph/lookupsubjects_test.go @@ -0,0 +1,218 @@ +package graph + +import ( + "context" + "sort" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + + "github.com/authzed/spicedb/pkg/datastore/revision" + 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) { + revision := revision.NewFromDecimal(decimal.NewFromInt(1)) + limits, _ := newLimitTracker(context.Background(), tc.limit) + + var cursor *v1.Cursor + if tc.subjectIdCursor != "" { + cursor = &v1.Cursor{ + AtRevision: revision.String(), + Sections: []string{afterSubjectIDCursorSection, tc.subjectIdCursor}, + } + } + + ci, err := newCursorInformation(cursor, revision, limits) + 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/namespace/typesystem.go b/internal/namespace/typesystem.go index fb10fec3ec..3b0b40e2dc 100644 --- a/internal/namespace/typesystem.go +++ b/internal/namespace/typesystem.go @@ -129,6 +129,16 @@ func (nts *TypeSystem) HasRelation(relationName string) bool { return ok } +// 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 { diff --git a/internal/namespace/typesystem_test.go b/internal/namespace/typesystem_test.go index 7e438f8b5b..0b9a91f673 100644 --- a/internal/namespace/typesystem_test.go +++ b/internal/namespace/typesystem_test.go @@ -458,6 +458,16 @@ 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("IsPermission", func(t *testing.T) { require.False(t, vts.IsPermission("somenonpermission")) diff --git a/internal/services/integrationtesting/consistency_test.go b/internal/services/integrationtesting/consistency_test.go index 98004012b9..7dd2ff8906 100644 --- a/internal/services/integrationtesting/consistency_test.go +++ b/internal/services/integrationtesting/consistency_test.go @@ -179,6 +179,7 @@ func testForEachResource( ) { t.Helper() + encountered := util.NewSet[string]() for _, resourceType := range vctx.clusterAndData.Populated.NamespaceDefinitions { resources, ok := vctx.accessibilitySet.ResourcesByNamespace.Get(resourceType.Name) if !ok { @@ -190,13 +191,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) }) } } @@ -365,7 +372,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 @@ -427,152 +434,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 2beb30fea9..d2833c3839 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) } func optionalizeRelation(relation string) string { @@ -190,12 +190,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 } @@ -213,13 +213,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) { @@ -227,10 +230,11 @@ 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 } diff --git a/internal/services/v1/hash.go b/internal/services/v1/hash.go index 58b5db2930..cb2c916b89 100644 --- a/internal/services/v1/hash.go +++ b/internal/services/v1/hash.go @@ -43,6 +43,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 62742029bb..946fcfcf5a 100644 --- a/internal/services/v1/hash_test.go +++ b/internal/services/v1/hash_test.go @@ -397,3 +397,187 @@ func TestLRHashStability(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 7903a7f38b..1096a27ad6 100644 --- a/internal/services/v1/permissions.go +++ b/internal/services/v1/permissions.go @@ -484,80 +484,147 @@ 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 + remainingLimit := 0 + + lsRequestHash, err := computeLSRequestHash(req) + if err != nil { + return shared.RewriteError(ctx, err) + } + + if req.OptionalCursor != nil { + decodedCursor, err := cursor.DecodeToDispatchCursor(req.OptionalCursor, lsRequestHash) + if err != nil { + return shared.RewriteError(ctx, err) } + currentCursor = decodedCursor + } + + if req.OptionalConcreteLimit > 0 { + remainingLimit = int(req.OptionalConcreteLimit) + } - for _, foundSubject := range foundSubjects.FoundSubjects { - excludedSubjectIDs := make([]string, 0, len(foundSubject.ExcludedSubjects)) - for _, excludedSubject := range foundSubject.ExcludedSubjects { - excludedSubjectIDs = append(excludedSubjectIDs, excludedSubject.SubjectId) + for { + countSubjectsFound := 0 + 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") } - 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)) + for _, excludedSubject := range foundSubject.ExcludedSubjects { + excludedSubjectIDs = append(excludedSubjectIDs, excludedSubject.SubjectId) + } + + excludedSubjects := make([]*v1.ResolvedSubject, 0, len(foundSubject.ExcludedSubjects)) + for _, excludedSubject := range foundSubject.ExcludedSubjects { + resolvedExcludedSubject, err := foundSubjectToResolvedSubject(ctx, excludedSubject, caveatContext, ds) + if err != nil { + return err + } + + if resolvedExcludedSubject == nil { + continue + } + + excludedSubjects = append(excludedSubjects, resolvedExcludedSubject) + } + + subject, err := foundSubjectToResolvedSubject(ctx, foundSubject, caveatContext, ds) if err != nil { return err } - - if resolvedExcludedSubject == nil { + if subject == nil { continue } - excludedSubjects = append(excludedSubjects, resolvedExcludedSubject) - } + // NOTE: we need to reompute the cursor here because we get multiple results back from DispatchLookupSubjects + // in one message. + dispatchCursor, err := graph.CursorForFoundSubjectID(subject.SubjectObjectId, result.AfterResponseCursor, atRevision) + if err != nil { + return err + } - subject, err := foundSubjectToResolvedSubject(ctx, foundSubject, caveatContext, ds) - if err != nil { - return err - } - if subject == nil { - continue - } + encodedCursor, err := cursor.EncodeFromDispatchCursor( + dispatchCursor, + lsRequestHash, + ) + 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 - }) - if err != nil { - return err + currentCursor = dispatchCursor + + if subject.SubjectObjectId != tuple.PublicWildcard { + countSubjectsFound++ + } + + 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 - }) + dispatchpkg.AddResponseMetadata(respMetadata, result.Metadata) + return nil + }) - 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), + 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 shared.RewriteError(ctx, err) - } + stream) + if err != nil { + return shared.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 { + return nil + } + + // Otherwise, subtract the number of results returned and check for early termination. + remainingLimit -= countSubjectsFound + if remainingLimit <= 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 83672b9a98..4209fe1eb0 100644 --- a/internal/services/v1/permissions_test.go +++ b/internal/services/v1/permissions_test.go @@ -1517,3 +1517,133 @@ func TestLookupResourcesWithCursors(t *testing.T) { }) } } + +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 := util.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), int(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) + }) + } + }) + } + }) + } +} diff --git a/pkg/proto/dispatch/v1/dispatch.pb.go b/pkg/proto/dispatch/v1/dispatch.pb.go index 1d17be8585..d4013c3dd8 100644 --- a/pkg/proto/dispatch/v1/dispatch.pb.go +++ b/pkg/proto/dispatch/v1/dispatch.pb.go @@ -1185,6 +1185,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() { @@ -1247,6 +1253,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 @@ -1364,6 +1384,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() { @@ -1412,6 +1433,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 @@ -1892,7 +1920,7 @@ var file_dispatch_v1_dispatch_proto_rawDesc = []byte{ 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, 0x1d, 0x44, 0x69, 0x73, 0x70, 0x61, + 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, 0x32, 0x19, 0x2e, 0x64, 0x69, 0x73, @@ -1911,149 +1939,160 @@ var file_dispatch_v1_dispatch_proto_rawDesc = []byte{ 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, + 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, 0x6b, 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, 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, 0xf8, 0x03, 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, + 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, 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, 0x6b, 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, 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, 0xf8, 0x03, 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, 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, + 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, 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, 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, + 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 ( @@ -2142,35 +2181,37 @@ 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 - 9, // 45: dispatch.v1.DispatchCheckResponse.ResultsByResourceIdEntry.value:type_name -> dispatch.v1.ResourceCheckResult - 21, // 46: dispatch.v1.DispatchLookupSubjectsResponse.FoundSubjectsByResourceIdEntry.value:type_name -> dispatch.v1.FoundSubjects - 9, // 47: dispatch.v1.CheckDebugTrace.ResultsEntry.value:type_name -> dispatch.v1.ResourceCheckResult - 7, // 48: dispatch.v1.DispatchService.DispatchCheck:input_type -> dispatch.v1.DispatchCheckRequest - 10, // 49: dispatch.v1.DispatchService.DispatchExpand:input_type -> dispatch.v1.DispatchExpandRequest - 13, // 50: dispatch.v1.DispatchService.DispatchReachableResources:input_type -> dispatch.v1.DispatchReachableResourcesRequest - 16, // 51: dispatch.v1.DispatchService.DispatchLookupResources:input_type -> dispatch.v1.DispatchLookupResourcesRequest - 19, // 52: dispatch.v1.DispatchService.DispatchLookupSubjects:input_type -> dispatch.v1.DispatchLookupSubjectsRequest - 8, // 53: dispatch.v1.DispatchService.DispatchCheck:output_type -> dispatch.v1.DispatchCheckResponse - 11, // 54: dispatch.v1.DispatchService.DispatchExpand:output_type -> dispatch.v1.DispatchExpandResponse - 15, // 55: dispatch.v1.DispatchService.DispatchReachableResources:output_type -> dispatch.v1.DispatchReachableResourcesResponse - 18, // 56: dispatch.v1.DispatchService.DispatchLookupResources:output_type -> dispatch.v1.DispatchLookupResourcesResponse - 22, // 57: dispatch.v1.DispatchService.DispatchLookupSubjects:output_type -> dispatch.v1.DispatchLookupSubjectsResponse - 53, // [53:58] is the sub-list for method output_type - 48, // [48:53] is the sub-list for method input_type - 48, // [48:48] is the sub-list for extension type_name - 48, // [48:48] is the sub-list for extension extendee - 0, // [0:48] 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 + 9, // 47: dispatch.v1.DispatchCheckResponse.ResultsByResourceIdEntry.value:type_name -> dispatch.v1.ResourceCheckResult + 21, // 48: dispatch.v1.DispatchLookupSubjectsResponse.FoundSubjectsByResourceIdEntry.value:type_name -> dispatch.v1.FoundSubjects + 9, // 49: dispatch.v1.CheckDebugTrace.ResultsEntry.value:type_name -> dispatch.v1.ResourceCheckResult + 7, // 50: dispatch.v1.DispatchService.DispatchCheck:input_type -> dispatch.v1.DispatchCheckRequest + 10, // 51: dispatch.v1.DispatchService.DispatchExpand:input_type -> dispatch.v1.DispatchExpandRequest + 13, // 52: dispatch.v1.DispatchService.DispatchReachableResources:input_type -> dispatch.v1.DispatchReachableResourcesRequest + 16, // 53: dispatch.v1.DispatchService.DispatchLookupResources:input_type -> dispatch.v1.DispatchLookupResourcesRequest + 19, // 54: dispatch.v1.DispatchService.DispatchLookupSubjects:input_type -> dispatch.v1.DispatchLookupSubjectsRequest + 8, // 55: dispatch.v1.DispatchService.DispatchCheck:output_type -> dispatch.v1.DispatchCheckResponse + 11, // 56: dispatch.v1.DispatchService.DispatchExpand:output_type -> dispatch.v1.DispatchExpandResponse + 15, // 57: dispatch.v1.DispatchService.DispatchReachableResources:output_type -> dispatch.v1.DispatchReachableResourcesResponse + 18, // 58: dispatch.v1.DispatchService.DispatchLookupResources:output_type -> dispatch.v1.DispatchLookupResourcesResponse + 22, // 59: dispatch.v1.DispatchService.DispatchLookupSubjects:output_type -> dispatch.v1.DispatchLookupSubjectsResponse + 55, // [55:60] is the sub-list for method output_type + 50, // [50:55] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] 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 8efb99e7c2..703c417a20 100644 --- a/pkg/proto/dispatch/v1/dispatch.pb.validate.go +++ b/pkg/proto/dispatch/v1/dispatch.pb.validate.go @@ -2297,6 +2297,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) } @@ -2773,6 +2804,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 9a7a39e1db..f2c31704d7 100644 --- a/pkg/proto/dispatch/v1/dispatch_vtproto.pb.go +++ b/pkg/proto/dispatch/v1/dispatch_vtproto.pb.go @@ -364,7 +364,9 @@ func (m *DispatchLookupSubjectsRequest) CloneVT() *DispatchLookupSubjectsRequest return (*DispatchLookupSubjectsRequest)(nil) } r := &DispatchLookupSubjectsRequest{ - Metadata: m.Metadata.CloneVT(), + Metadata: m.Metadata.CloneVT(), + OptionalLimit: m.OptionalLimit, + OptionalCursor: m.OptionalCursor.CloneVT(), } if rhs := m.ResourceRelation; rhs != nil { if vtpb, ok := interface{}(rhs).(interface{ CloneVT() *v1.RelationReference }); ok { @@ -456,7 +458,8 @@ func (m *DispatchLookupSubjectsResponse) CloneVT() *DispatchLookupSubjectsRespon return (*DispatchLookupSubjectsResponse)(nil) } r := &DispatchLookupSubjectsResponse{ - Metadata: m.Metadata.CloneVT(), + Metadata: m.Metadata.CloneVT(), + AfterResponseCursor: m.AfterResponseCursor.CloneVT(), } if rhs := m.FoundSubjectsByResourceId; rhs != nil { tmpContainer := make(map[string]*FoundSubjects, len(rhs)) @@ -1033,6 +1036,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) } @@ -1150,6 +1159,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) } @@ -2214,6 +2226,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 = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x32 + } + if m.OptionalLimit != 0 { + i = 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) @@ -2429,6 +2456,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 = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } if m.Metadata != nil { size, err := m.Metadata.MarshalToSizedBufferVT(dAtA[:i]) if err != nil { @@ -3110,6 +3147,13 @@ func (m *DispatchLookupSubjectsRequest) SizeVT() (n int) { } n += 1 + l + sov(uint64(l)) } + if m.OptionalLimit != 0 { + n += 1 + sov(uint64(m.OptionalLimit)) + } + if m.OptionalCursor != nil { + l = m.OptionalCursor.SizeVT() + n += 1 + l + sov(uint64(l)) + } n += len(m.unknownFields) return n } @@ -3183,6 +3227,10 @@ func (m *DispatchLookupSubjectsResponse) SizeVT() (n int) { l = m.Metadata.SizeVT() n += 1 + l + sov(uint64(l)) } + if m.AfterResponseCursor != nil { + l = m.AfterResponseCursor.SizeVT() + n += 1 + l + sov(uint64(l)) + } n += len(m.unknownFields) return n } @@ -5596,6 +5644,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 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 ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return 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 := skip(dAtA[iNdEx:]) @@ -6058,6 +6161,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 ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return 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 := skip(dAtA[iNdEx:]) diff --git a/pkg/util/chunking.go b/pkg/util/chunking.go index 76c989ff32..5e78391227 100644 --- a/pkg/util/chunking.go +++ b/pkg/util/chunking.go @@ -6,6 +6,13 @@ import ( // ForEachChunk executes the given handler for each chunk of items in the slice. func ForEachChunk[T any](data []T, chunkSize uint16, handler func(items []T)) { + _, _ = ForEachChunkUntil[T](data, chunkSize, func(items []T) (bool, error) { + handler(items) + return true, nil + }) +} + +func ForEachChunkUntil[T any](data []T, chunkSize uint16, handler func(items []T) (bool, error)) (bool, error) { if chunkSize == 0 { logging.Warn().Int("invalid-chunk-size", int(chunkSize)).Msg("ForEachChunk got an invalid chunk size; defaulting to 1") chunkSize = 1 @@ -22,7 +29,14 @@ func ForEachChunk[T any](data []T, chunkSize uint16, handler func(items []T)) { chunk := data[chunkStart:chunkEnd] if len(chunk) > 0 { - handler(chunk) + ok, err := handler(chunk) + if err != nil { + return false, err + } + if !ok { + return ok, nil + } } } + return true, nil } diff --git a/pkg/util/chunking_test.go b/pkg/util/chunking_test.go index f98879eafb..01a0f4f5ca 100644 --- a/pkg/util/chunking_test.go +++ b/pkg/util/chunking_test.go @@ -29,3 +29,47 @@ func TestForEachChunk(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) { + data := []int{} + for i := 0; i < datasize; i++ { + data = append(data, i) + } + + 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/proto/internal/dispatch/v1/dispatch.proto b/proto/internal/dispatch/v1/dispatch.proto index 6dbac46502..399fde1341 100644 --- a/proto/internal/dispatch/v1/dispatch.proto +++ b/proto/internal/dispatch/v1/dispatch.proto @@ -173,6 +173,14 @@ message DispatchLookupSubjectsRequest { 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 { @@ -188,6 +196,7 @@ message FoundSubjects { message DispatchLookupSubjectsResponse { map found_subjects_by_resource_id = 1; ResponseMeta metadata = 2; + Cursor after_response_cursor = 3; } message ResolverMeta {