From 263823bbf684d5d19aa09c1ea1e46c2655e9706e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 13 Jun 2024 14:42:26 -0400 Subject: [PATCH] Add dispatching support for intersection arrows Fixes #597 --- internal/caveats/builder.go | 20 + internal/dispatch/graph/check_test.go | 715 +++++++++++++++++- internal/dispatch/graph/expand_test.go | 176 ++++- .../dispatch/graph/lookupresources_test.go | 40 + .../dispatch/graph/lookupsubjects_test.go | 283 ++++++- internal/graph/check.go | 252 +++++- internal/graph/expand.go | 32 +- internal/graph/graph.go | 8 + internal/graph/lookupsubjects.go | 220 +++++- internal/graph/membershipset.go | 30 + internal/namespace/canonicalization.go | 54 +- internal/namespace/canonicalization_test.go | 43 ++ .../caveatedintersectionarrow.yaml | 55 ++ .../testconfigs/intersectionarrow.yaml | 49 ++ internal/testutil/subjects.go | 2 +- pkg/graph/walker.go | 14 +- pkg/namespace/builder.go | 2 +- pkg/schemadsl/parser/parser_impl.go | 19 - pkg/typesystem/reachabilitygraph_test.go | 166 ++++ pkg/typesystem/reachabilitygraphbuilder.go | 136 ++-- pkg/typesystem/typesystem.go | 65 +- 21 files changed, 2258 insertions(+), 123 deletions(-) create mode 100644 internal/services/integrationtesting/testconfigs/caveatedintersectionarrow.yaml create mode 100644 internal/services/integrationtesting/testconfigs/intersectionarrow.yaml diff --git a/internal/caveats/builder.go b/internal/caveats/builder.go index f5a46fb572..faa4413158 100644 --- a/internal/caveats/builder.go +++ b/internal/caveats/builder.go @@ -1,6 +1,8 @@ package caveats import ( + "google.golang.org/protobuf/types/known/structpb" + core "github.com/authzed/spicedb/pkg/proto/core/v1" ) @@ -34,6 +36,24 @@ func CaveatExprForTesting(name string) *core.CaveatExpression { } } +// CaveatExprForTesting returns a CaveatExpression referencing a caveat with the given name and +// empty context. +func MustCaveatExprForTestingWithContext(name string, context map[string]any) *core.CaveatExpression { + contextStruct, err := structpb.NewStruct(context) + if err != nil { + panic(err) + } + + return &core.CaveatExpression{ + OperationOrCaveat: &core.CaveatExpression_Caveat{ + Caveat: &core.ContextualizedCaveat{ + CaveatName: name, + Context: contextStruct, + }, + }, + } +} + // ShortcircuitedOr combines two caveat expressions via an `||`. If one of the expressions is nil, // then the entire expression is *short-circuited*, and a nil is returned. func ShortcircuitedOr(first *core.CaveatExpression, second *core.CaveatExpression) *core.CaveatExpression { diff --git a/internal/dispatch/graph/check_test.go b/internal/dispatch/graph/check_test.go index 59cff585e4..e5ba8b0039 100644 --- a/internal/dispatch/graph/check_test.go +++ b/internal/dispatch/graph/check_test.go @@ -22,6 +22,7 @@ import ( "github.com/authzed/spicedb/pkg/genutil/mapz" core "github.com/authzed/spicedb/pkg/proto/core/v1" v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" + "github.com/authzed/spicedb/pkg/testutil" "github.com/authzed/spicedb/pkg/tuple" ) @@ -291,13 +292,6 @@ func TestCheckMetadata(t *testing.T) { } } -func addFrame(trace *v1.CheckDebugTrace, foundFrames *mapz.Set[string]) { - foundFrames.Insert(fmt.Sprintf("%s:%s#%s", trace.Request.ResourceRelation.Namespace, strings.Join(trace.Request.ResourceIds, ","), trace.Request.ResourceRelation.Relation)) - for _, subTrace := range trace.SubProblems { - addFrame(subTrace, foundFrames) - } -} - func TestCheckPermissionOverSchema(t *testing.T) { testCases := []struct { name string @@ -306,6 +300,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { resource *core.ObjectAndRelation subject *core.ObjectAndRelation expectedPermissionship v1.ResourceCheckResult_Membership + expectedCaveat *core.CaveatExpression }{ { "basic union", @@ -322,6 +317,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_MEMBER, + nil, }, { "basic intersection", @@ -339,6 +335,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_MEMBER, + nil, }, { "basic exclusion", @@ -355,6 +352,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_MEMBER, + nil, }, { "basic union, multiple branches", @@ -372,6 +370,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_MEMBER, + nil, }, { "basic union no permission", @@ -386,6 +385,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_NOT_MEMBER, + nil, }, { "basic intersection no permission", @@ -402,6 +402,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_NOT_MEMBER, + nil, }, { "basic exclusion no permission", @@ -419,6 +420,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_NOT_MEMBER, + nil, }, { "exclusion with multiple branches", @@ -444,6 +446,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_MEMBER, + nil, }, { "intersection with multiple branches", @@ -469,6 +472,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_MEMBER, + nil, }, { "exclusion with multiple branches no permission", @@ -495,6 +499,7 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_NOT_MEMBER, + nil, }, { "intersection with multiple branches no permission", @@ -519,6 +524,690 @@ func TestCheckPermissionOverSchema(t *testing.T) { ONR("document", "first", "view"), ONR("user", "tom", "..."), v1.ResourceCheckResult_NOT_MEMBER, + nil, + }, + { + "basic arrow", + `definition user {} + + definition organization { + relation member: user + } + + definition document { + relation orgs: organization + permission view = orgs->member + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("document:first#orgs@organization:second"), + tuple.MustParse("organization:second#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "basic any arrow", + `definition user {} + + definition organization { + relation member: user + } + + definition document { + relation orgs: organization + permission view = orgs.any(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("document:first#orgs@organization:second"), + tuple.MustParse("organization:second#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "basic all arrow negative", + `definition user {} + + definition organization { + relation member: user + } + + definition document { + relation orgs: organization + permission view = orgs.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("document:first#orgs@organization:second"), + tuple.MustParse("organization:second#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_NOT_MEMBER, + nil, + }, + { + "basic all arrow positive", + `definition user {} + + definition organization { + relation member: user + } + + definition document { + relation orgs: organization + permission view = orgs.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("document:first#orgs@organization:second"), + tuple.MustParse("organization:first#member@user:tom"), + tuple.MustParse("organization:second#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "basic all arrow positive with different types", + `definition user {} + + definition organization { + relation member: user + } + + definition someotherresource { + relation member: user + } + + definition document { + relation orgs: organization | someotherresource + permission view = orgs.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("document:first#orgs@organization:second"), + tuple.MustParse("organization:first#member@user:tom"), + tuple.MustParse("organization:second#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "basic all arrow negative over different types", + `definition user {} + + definition organization { + relation member: user + } + + definition someotherresource { + relation member: user + } + + definition document { + relation orgs: organization | someotherresource + permission view = orgs.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("document:first#orgs@organization:second"), + tuple.MustParse("document:first#orgs@someotherresource:other"), + tuple.MustParse("organization:first#member@user:tom"), + tuple.MustParse("organization:second#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_NOT_MEMBER, + nil, + }, + { + "basic all arrow positive over different types", + `definition user {} + + definition organization { + relation member: user + } + + definition someotherresource { + relation member: user + } + + definition document { + relation orgs: organization | someotherresource + permission view = orgs.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("document:first#orgs@organization:second"), + tuple.MustParse("document:first#orgs@someotherresource:other"), + tuple.MustParse("organization:first#member@user:tom"), + tuple.MustParse("organization:second#member@user:tom"), + tuple.MustParse("someotherresource:other#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "all arrow for single org", + `definition user {} + + definition organization { + relation member: user + } + + definition document { + relation orgs: organization + permission view = orgs.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:first#orgs@organization:first"), + tuple.MustParse("organization:first#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "all arrow for no orgs", + `definition user {} + + definition organization { + relation member: user + } + + definition document { + relation orgs: organization + permission view = orgs.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("organization:first#member@user:tom"), + }, + ONR("document", "first", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_NOT_MEMBER, + nil, + }, + { + "view_by_all negative", + ` definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + definition resource { + relation team: team + permission view_by_all = team.all(member) + permission view_by_any = team.any(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("team:first#direct_member@user:tom"), + tuple.MustParse("team:first#direct_member@user:fred"), + tuple.MustParse("team:first#direct_member@user:sarah"), + tuple.MustParse("team:second#direct_member@user:fred"), + tuple.MustParse("team:second#direct_member@user:sarah"), + tuple.MustParse("team:third#direct_member@user:sarah"), + tuple.MustParse("resource:oneteam#team@team:first"), + tuple.MustParse("resource:twoteams#team@team:first"), + tuple.MustParse("resource:twoteams#team@team:second"), + tuple.MustParse("resource:threeteams#team@team:first"), + tuple.MustParse("resource:threeteams#team@team:second"), + tuple.MustParse("resource:threeteams#team@team:third"), + }, + ONR("resource", "threeteams", "view_by_all"), + ONR("user", "fred", "..."), + v1.ResourceCheckResult_NOT_MEMBER, + nil, + }, + { + "view_by_any positive", + ` definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + definition resource { + relation team: team + relation viewer: user + permission view_by_all = team.all(member) + viewer + permission view_by_any = team.any(member) + viewer + }`, + []*core.RelationTuple{ + tuple.MustParse("team:first#direct_member@user:tom"), + tuple.MustParse("team:first#direct_member@user:fred"), + tuple.MustParse("team:first#direct_member@user:sarah"), + tuple.MustParse("team:second#direct_member@user:fred"), + tuple.MustParse("team:second#direct_member@user:sarah"), + tuple.MustParse("team:third#direct_member@user:sarah"), + tuple.MustParse("resource:oneteam#team@team:first"), + tuple.MustParse("resource:twoteams#team@team:first"), + tuple.MustParse("resource:twoteams#team@team:second"), + tuple.MustParse("resource:threeteams#team@team:first"), + tuple.MustParse("resource:threeteams#team@team:second"), + tuple.MustParse("resource:threeteams#team@team:third"), + tuple.MustParse("resource:oneteam#viewer@user:rachel"), + }, + ONR("resource", "threeteams", "view_by_any"), + ONR("user", "fred", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "view_by_any positive directly", + ` definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + definition resource { + relation team: team + relation viewer: user + permission view_by_all = team.all(member) + viewer + permission view_by_any = team.any(member) + viewer + }`, + []*core.RelationTuple{ + tuple.MustParse("team:first#direct_member@user:tom"), + tuple.MustParse("team:first#direct_member@user:fred"), + tuple.MustParse("team:first#direct_member@user:sarah"), + tuple.MustParse("team:second#direct_member@user:fred"), + tuple.MustParse("team:second#direct_member@user:sarah"), + tuple.MustParse("team:third#direct_member@user:sarah"), + tuple.MustParse("resource:oneteam#team@team:first"), + tuple.MustParse("resource:twoteams#team@team:first"), + tuple.MustParse("resource:twoteams#team@team:second"), + tuple.MustParse("resource:threeteams#team@team:first"), + tuple.MustParse("resource:threeteams#team@team:second"), + tuple.MustParse("resource:threeteams#team@team:third"), + tuple.MustParse("resource:oneteam#viewer@user:rachel"), + }, + ONR("resource", "oneteam", "view_by_any"), + ONR("user", "rachel", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "caveated intersection arrow", + ` definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam == 42 + } + + definition resource { + relation team: team with somecaveat + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("team:first#direct_member@user:tom"), + tuple.MustParse("resource:oneteam#team@team:first[somecaveat]"), + }, + ONR("resource", "oneteam", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAndCtx("somecaveat", nil), + }, + { + "intersection arrow with caveated member", + ` definition user {} + + definition team { + relation direct_member: user with somecaveat + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam == 42 + } + + definition resource { + relation team: team + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("team:first#direct_member@user:tom[somecaveat]"), + tuple.MustParse("resource:oneteam#team@team:first"), + }, + ONR("resource", "oneteam", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAndCtx("somecaveat", nil), + }, + { + "caveated intersection arrow with caveated member", + ` definition user {} + + definition team { + relation direct_member: user with somecaveat + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam == 42 + } + + definition resource { + relation team: team with somecaveat + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse("team:first#direct_member@user:tom[somecaveat]"), + tuple.MustParse("resource:oneteam#team@team:first[somecaveat]"), + }, + ONR("resource", "oneteam", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAndCtx("somecaveat", nil), + }, + { + "caveated intersection arrow with caveated member, different context", + `definition user {} + + definition team { + relation direct_member: user with anothercaveat + permission member = direct_member + } + + caveat anothercaveat(someparam int) { + someparam == 43 + } + + caveat somecaveat(someparam int) { + someparam == 42 + } + + definition resource { + relation team: team with somecaveat + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse(`team:first#direct_member@user:tom[anothercaveat:{"someparam": 43}]`), + tuple.MustParse(`resource:oneteam#team@team:first[somecaveat:{"someparam": 42}]`), + }, + ONR("resource", "oneteam", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAnd( + caveatAndCtx("anothercaveat", map[string]any{"someparam": int64(43)}), + caveatAndCtx("somecaveat", map[string]any{"someparam": int64(42)}), + ), + }, + { + "caveated intersection arrow with multiple caveated branches", + `definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam >= 42 + } + + definition resource { + relation team: team with somecaveat + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse(`resource:someresource#team@team:first[somecaveat:{"someparam": 41}]`), + tuple.MustParse(`resource:someresource#team@team:second[somecaveat:{"someparam": 42}]`), + tuple.MustParse(`team:first#direct_member@user:tom`), + tuple.MustParse(`team:second#direct_member@user:tom`), + }, + ONR("resource", "someresource", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAnd( + caveatAndCtx("somecaveat", map[string]any{"someparam": int64(41)}), + caveatAndCtx("somecaveat", map[string]any{"someparam": int64(42)}), + ), + }, + + { + "caveated intersection arrow with multiple caveated members", + `definition user {} + + definition team { + relation direct_member: user with somecaveat + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam >= 42 + } + + definition resource { + relation team: team + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse(`resource:someresource#team@team:first`), + tuple.MustParse(`resource:someresource#team@team:second`), + tuple.MustParse(`team:first#direct_member@user:tom[somecaveat:{"someparam": 41}]`), + tuple.MustParse(`team:second#direct_member@user:tom[somecaveat:{"someparam": 42}]`), + }, + ONR("resource", "someresource", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAnd( + caveatAndCtx("somecaveat", map[string]any{"someparam": int64(41)}), + caveatAndCtx("somecaveat", map[string]any{"someparam": int64(42)}), + ), + }, + { + "caveated intersection arrow with one caveated branch", + `definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam >= 42 + } + + definition resource { + relation team: team with somecaveat | team + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse(`resource:someresource#team@team:first`), + tuple.MustParse(`resource:someresource#team@team:second[somecaveat:{"someparam": 42}]`), + tuple.MustParse(`team:first#direct_member@user:tom`), + tuple.MustParse(`team:second#direct_member@user:tom`), + }, + ONR("resource", "someresource", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAndCtx("somecaveat", map[string]any{"someparam": int64(42)}), + }, + { + "caveated intersection arrow with one caveated member", + `definition user {} + + definition team { + relation direct_member: user with somecaveat + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam >= 42 + } + + definition resource { + relation team: team + permission view_by_all = team.all(member) + }`, + []*core.RelationTuple{ + tuple.MustParse(`resource:someresource#team@team:first`), + tuple.MustParse(`resource:someresource#team@team:second`), + tuple.MustParse(`team:first#direct_member@user:tom`), + tuple.MustParse(`team:second#direct_member@user:tom[somecaveat:{"someparam": 42}]`), + }, + ONR("resource", "someresource", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAndCtx("somecaveat", map[string]any{"someparam": int64(42)}), + }, + { + "caveated intersection arrow multiple paths to the same subject", + `definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + caveat somecaveat(someparam int) { + someparam >= 42 + } + + definition resource { + relation team: team with somecaveat | team#direct_member with somecaveat + permission view_by_all = team.all(member) // Note: this points to the same team twice + }`, + []*core.RelationTuple{ + tuple.MustParse(`resource:someresource#team@team:first`), + tuple.MustParse(`resource:someresource#team@team:first#direct_member[somecaveat]`), + tuple.MustParse(`team:first#direct_member@user:tom`), + }, + ONR("resource", "someresource", "view_by_all"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_CAVEATED_MEMBER, + caveatAndCtx("somecaveat", nil), + }, + { + "recursive all arrow positive result", + `definition user {} + + definition folder { + relation parent: folder + relation owner: user + + permission view = parent.all(owner) + } + + definition document { + relation folder: folder + permission view = folder.all(view) + }`, + []*core.RelationTuple{ + tuple.MustParse("folder:root1#owner@user:tom"), + tuple.MustParse("folder:root1#owner@user:fred"), + tuple.MustParse("folder:root1#owner@user:sarah"), + tuple.MustParse("folder:root2#owner@user:fred"), + tuple.MustParse("folder:root2#owner@user:sarah"), + + tuple.MustParse("folder:child1#parent@folder:root1"), + tuple.MustParse("folder:child1#parent@folder:root2"), + + tuple.MustParse("folder:child2#parent@folder:root1"), + tuple.MustParse("folder:child2#parent@folder:root2"), + + tuple.MustParse("document:doc1#folder@folder:child1"), + tuple.MustParse("document:doc1#folder@folder:child2"), + }, + ONR("document", "doc1", "view"), + ONR("user", "fred", "..."), + v1.ResourceCheckResult_MEMBER, + nil, + }, + { + "recursive all arrow negative result", + `definition user {} + + definition folder { + relation parent: folder + relation owner: user + + permission view = parent.all(owner) + } + + definition document { + relation folder: folder + permission view = folder.all(view) + }`, + []*core.RelationTuple{ + tuple.MustParse("folder:root1#owner@user:tom"), + tuple.MustParse("folder:root1#owner@user:fred"), + tuple.MustParse("folder:root1#owner@user:sarah"), + tuple.MustParse("folder:root2#owner@user:fred"), + tuple.MustParse("folder:root2#owner@user:sarah"), + + tuple.MustParse("folder:child1#parent@folder:root1"), + tuple.MustParse("folder:child1#parent@folder:root2"), + + tuple.MustParse("folder:child2#parent@folder:root1"), + tuple.MustParse("folder:child2#parent@folder:root2"), + + tuple.MustParse("document:doc1#folder@folder:child1"), + tuple.MustParse("document:doc1#folder@folder:child2"), + }, + ONR("document", "doc1", "view"), + ONR("user", "tom", "..."), + v1.ResourceCheckResult_NOT_MEMBER, + nil, + }, + { + "recursive all arrow negative result due to recursion missing a folder", + `definition user {} + + definition folder { + relation parent: folder + relation owner: user + + permission view = parent.all(owner) + } + + definition document { + relation folder: folder + permission view = folder.all(view) + }`, + []*core.RelationTuple{ + tuple.MustParse("folder:root1#owner@user:tom"), + tuple.MustParse("folder:root1#owner@user:fred"), + tuple.MustParse("folder:root1#owner@user:sarah"), + tuple.MustParse("folder:root2#owner@user:fred"), + tuple.MustParse("folder:root2#owner@user:sarah"), + + tuple.MustParse("folder:child1#parent@folder:root1"), + tuple.MustParse("folder:child1#parent@folder:root2"), + tuple.MustParse("folder:child1#parent@folder:root3"), + + tuple.MustParse("folder:child2#parent@folder:root1"), + tuple.MustParse("folder:child2#parent@folder:root2"), + + tuple.MustParse("document:doc1#folder@folder:child1"), + tuple.MustParse("document:doc1#folder@folder:child2"), + }, + ONR("document", "doc1", "view"), + ONR("user", "fred", "..."), + v1.ResourceCheckResult_NOT_MEMBER, + nil, }, } @@ -555,10 +1244,22 @@ func TestCheckPermissionOverSchema(t *testing.T) { } require.Equal(tc.expectedPermissionship, membership) + + if tc.expectedCaveat != nil { + require.NotEmpty(resp.ResultsByResourceId[tc.resource.ObjectId].Expression) + testutil.RequireProtoEqual(t, tc.expectedCaveat, resp.ResultsByResourceId[tc.resource.ObjectId].Expression, "mismatch in caveat") + } }) } } +func addFrame(trace *v1.CheckDebugTrace, foundFrames *mapz.Set[string]) { + foundFrames.Insert(fmt.Sprintf("%s:%s#%s", trace.Request.ResourceRelation.Namespace, strings.Join(trace.Request.ResourceIds, ","), trace.Request.ResourceRelation.Relation)) + for _, subTrace := range trace.SubProblems { + addFrame(subTrace, foundFrames) + } +} + func TestCheckDebugging(t *testing.T) { type expectedFrame struct { resourceType *core.RelationReference diff --git a/internal/dispatch/graph/expand_test.go b/internal/dispatch/graph/expand_test.go index 466e83f482..99b26526f3 100644 --- a/internal/dispatch/graph/expand_test.go +++ b/internal/dispatch/graph/expand_test.go @@ -305,7 +305,7 @@ func TestMaxDepthExpand(t *testing.T) { require.Error(err) } -func TestCaveatedExpand(t *testing.T) { +func TestExpandOverSchema(t *testing.T) { defer goleak.VerifyNone(t, goleakIgnores...) testCases := []struct { @@ -318,6 +318,180 @@ func TestCaveatedExpand(t *testing.T) { expansionMode v1.DispatchExpandRequest_ExpansionMode expectedTreeText string }{ + { + "basic any arrow", + ` + definition user {} + + definition folder { + relation viewer: user + } + + definition document { + relation folder: folder + permission view = folder->viewer + }`, + []*core.RelationTuple{ + tuple.MustParse("document:testdoc#folder@folder:testfolder1"), + tuple.MustParse("document:testdoc#folder@folder:testfolder2"), + tuple.MustParse("folder:testfolder1#viewer@user:tom"), + tuple.MustParse("folder:testfolder1#viewer@user:fred"), + tuple.MustParse("folder:testfolder2#viewer@user:sarah"), + }, + + tuple.ParseONR("document:testdoc#view"), + + v1.DispatchExpandRequest_SHALLOW, + `intermediate_node: { + operation: UNION + child_nodes: { + intermediate_node: { + operation: UNION + child_nodes: { + leaf_node: { + subjects: { + subject: { + namespace: "user" + object_id: "fred" + relation: "..." + } + } + subjects: { + subject: { + namespace: "user" + object_id: "tom" + relation: "..." + } + } + } + expanded: { + namespace: "folder" + object_id: "testfolder1" + relation: "viewer" + } + } + child_nodes: { + leaf_node: { + subjects: { + subject: { + namespace: "user" + object_id: "sarah" + relation: "..." + } + } + } + expanded: { + namespace: "folder" + object_id: "testfolder2" + relation: "viewer" + } + } + } + expanded: { + namespace: "document" + object_id: "testdoc" + relation: "view" + } + } + } + expanded: { + namespace: "document" + object_id: "testdoc" + relation: "view" + }`, + }, + { + "basic all arrow", + ` + definition user {} + + definition folder { + relation viewer: user + } + + definition document { + relation folder: folder + permission view = folder.all(viewer) + }`, + []*core.RelationTuple{ + tuple.MustParse("document:testdoc#folder@folder:testfolder1"), + tuple.MustParse("document:testdoc#folder@folder:testfolder2"), + tuple.MustParse("folder:testfolder1#viewer@user:tom"), + tuple.MustParse("folder:testfolder1#viewer@user:fred"), + tuple.MustParse("folder:testfolder2#viewer@user:tom"), + tuple.MustParse("folder:testfolder2#viewer@user:sarah"), + }, + + tuple.ParseONR("document:testdoc#view"), + + v1.DispatchExpandRequest_SHALLOW, + ` + intermediate_node: { + operation: UNION + child_nodes: { + intermediate_node: { + operation: INTERSECTION + child_nodes: { + leaf_node: { + subjects: { + subject: { + namespace: "user" + object_id: "fred" + relation: "..." + } + } + subjects: { + subject: { + namespace: "user" + object_id: "tom" + relation: "..." + } + } + } + expanded: { + namespace: "folder" + object_id: "testfolder1" + relation: "viewer" + } + } + child_nodes: { + leaf_node: { + subjects: { + subject: { + namespace: "user" + object_id: "sarah" + relation: "..." + } + } + subjects: { + subject: { + namespace: "user" + object_id: "tom" + relation: "..." + } + } + } + expanded: { + namespace: "folder" + object_id: "testfolder2" + relation: "viewer" + } + } + } + expanded: { + namespace: "document" + object_id: "testdoc" + relation: "view" + } + } + } + expanded: { + namespace: "document" + object_id: "testdoc" + relation: "view" + } + `, + }, { "basic caveated subject", ` diff --git a/internal/dispatch/graph/lookupresources_test.go b/internal/dispatch/graph/lookupresources_test.go index fbc73287b4..d7077a50af 100644 --- a/internal/dispatch/graph/lookupresources_test.go +++ b/internal/dispatch/graph/lookupresources_test.go @@ -544,6 +544,46 @@ func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { ONR("user", "tom", "..."), genResourceIds("document", 15100), }, + { + "all arrow", + `definition user {} + + definition folder { + relation viewer: user + } + + definition document { + relation parent: folder + relation viewer: user + permission view = parent.all(viewer) + viewer + }`, + []*core.RelationTuple{ + tuple.MustParse("document:doc0#parent@folder:folder0"), + tuple.MustParse("folder:folder0#viewer@user:tom"), + + tuple.MustParse("document:doc1#parent@folder:folder1-1"), + tuple.MustParse("document:doc1#parent@folder:folder1-2"), + tuple.MustParse("document:doc1#parent@folder:folder1-3"), + tuple.MustParse("folder:folder1-1#viewer@user:tom"), + tuple.MustParse("folder:folder1-2#viewer@user:tom"), + tuple.MustParse("folder:folder1-3#viewer@user:tom"), + + tuple.MustParse("document:doc2#parent@folder:folder2-1"), + tuple.MustParse("document:doc2#parent@folder:folder2-2"), + tuple.MustParse("document:doc2#parent@folder:folder2-3"), + tuple.MustParse("folder:folder2-1#viewer@user:tom"), + tuple.MustParse("folder:folder2-2#viewer@user:tom"), + + tuple.MustParse("document:doc3#parent@folder:folder3-1"), + + tuple.MustParse("document:doc4#viewer@user:tom"), + + tuple.MustParse("document:doc5#viewer@user:fred"), + }, + RR("document", "view"), + ONR("user", "tom", "..."), + []string{"doc0", "doc1", "doc4"}, + }, } for _, tc := range testCases { diff --git a/internal/dispatch/graph/lookupsubjects_test.go b/internal/dispatch/graph/lookupsubjects_test.go index 3aabce3592..15ade5e692 100644 --- a/internal/dispatch/graph/lookupsubjects_test.go +++ b/internal/dispatch/graph/lookupsubjects_test.go @@ -24,6 +24,7 @@ import ( var ( caveatexpr = caveats.CaveatExprForTesting + caveatAndCtx = caveats.MustCaveatExprForTestingWithContext caveatAnd = caveats.And caveatInvert = caveats.Invert ) @@ -275,7 +276,7 @@ func TestLookupSubjectsDispatchCount(t *testing.T) { } } -func TestCaveatedLookupSubjects(t *testing.T) { +func TestLookupSubjectsOverSchema(t *testing.T) { testCases := []struct { name string schema string @@ -707,6 +708,286 @@ func TestCaveatedLookupSubjects(t *testing.T) { }, }, }, + { + "simple arrow", + `definition user {} + + definition folder { + relation viewer: user + permission view = viewer + } + + definition document { + relation folder: folder + permission view = folder->view + }`, + []*corev1.RelationTuple{ + tuple.MustParse("folder:folder1#viewer@user:tom"), + tuple.MustParse("folder:folder1#viewer@user:fred"), + tuple.MustParse("document:somedoc#folder@folder:folder1"), + }, + ONR("document", "somedoc", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + }, + { + SubjectId: "fred", + }, + }, + }, + { + "simple any arrow", + `definition user {} + + definition folder { + relation viewer: user + permission view = viewer + } + + definition document { + relation folder: folder + permission view = folder.any(view) + }`, + []*corev1.RelationTuple{ + tuple.MustParse("folder:folder1#viewer@user:tom"), + tuple.MustParse("folder:folder1#viewer@user:fred"), + tuple.MustParse("document:somedoc#folder@folder:folder1"), + }, + ONR("document", "somedoc", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + }, + { + SubjectId: "fred", + }, + }, + }, + { + "simple all arrow", + `definition user {} + + definition folder { + relation viewer: user + permission view = viewer + } + + definition document { + relation folder: folder + permission view = folder.all(view) + }`, + []*corev1.RelationTuple{ + tuple.MustParse("folder:folder1#viewer@user:tom"), + tuple.MustParse("folder:folder1#viewer@user:fred"), + tuple.MustParse("document:somedoc#folder@folder:folder1"), + }, + ONR("document", "somedoc", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + }, + { + SubjectId: "fred", + }, + }, + }, + { + "all arrow multiple", + `definition user {} + + definition folder { + relation viewer: user + permission view = viewer + } + + definition document { + relation folder: folder + permission view = folder.all(view) + }`, + []*corev1.RelationTuple{ + tuple.MustParse("folder:folder1#viewer@user:tom"), + tuple.MustParse("folder:folder1#viewer@user:fred"), + tuple.MustParse("folder:folder2#viewer@user:fred"), + tuple.MustParse("document:somedoc#folder@folder:folder1"), + tuple.MustParse("document:somedoc#folder@folder:folder2"), + }, + ONR("document", "somedoc", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "fred", + }, + }, + }, + { + "all arrow over multiple resource IDs", + `definition user {} + + definition organization { + relation member: user + } + + definition folder { + relation parent: organization + permission view = parent.all(member) + } + + definition document { + relation folder: folder + permission view = folder->view + }`, + []*corev1.RelationTuple{ + tuple.MustParse("document:somedoc#folder@folder:folder1"), + tuple.MustParse("document:somedoc#folder@folder:folder2"), + tuple.MustParse("folder:folder1#parent@organization:org1"), + tuple.MustParse("folder:folder2#parent@organization:org2"), + tuple.MustParse("folder:folder2#parent@organization:org3"), + tuple.MustParse("organization:org1#member@user:fred"), + tuple.MustParse("organization:org2#member@user:tom"), + tuple.MustParse("organization:org3#member@user:tom"), + tuple.MustParse("organization:org2#member@user:sarah"), + }, + ONR("document", "somedoc", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + }, + { + SubjectId: "fred", + }, + }, + }, + { + "intersection arrow over caveated teams", + `definition user {} + + definition team { + relation member: user | user with anothercaveat + } + + caveat caveat1(someparam1 int) { + someparam1 == 42 + } + + caveat caveat2(someparam2 int) { + someparam2 == 42 + } + + caveat anothercaveat(anotherparam int) { + anotherparam == 43 + } + + definition document { + relation team: team with caveat1 | team with caveat2 + permission view = team.all(member) + }`, + []*corev1.RelationTuple{ + tuple.MustParse(`document:somedoc#team@team:team1[caveat1:{":someparam1":42}]`), + tuple.MustParse(`document:somedoc#team@team:team2[caveat2:{":someparam2":43}]`), + tuple.MustParse(`team:team1#member@user:tom`), + tuple.MustParse(`team:team2#member@user:tom`), + tuple.MustParse(`team:team1#member@user:fred`), + tuple.MustParse(`team:team2#member@user:fred[anothercaveat:{":anotherparam":43}]`), + tuple.MustParse(`team:team1#member@user:sarah`), + }, + ONR("document", "somedoc", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + CaveatExpression: caveatAnd( + caveatAndCtx("caveat1", map[string]interface{}{"someparam1": 42}), + caveatAndCtx("caveat2", map[string]interface{}{"someparam2": 43}), + ), + }, + { + SubjectId: "fred", + CaveatExpression: caveatAnd( + caveatAnd( + caveatAndCtx("caveat1", map[string]interface{}{"someparam1": 42}), + caveatAndCtx("caveat2", map[string]interface{}{"someparam2": 43}), + ), + caveatAndCtx("anothercaveat", map[string]interface{}{"anotherparam": 43}), + ), + }, + }, + }, + { + "all arrow minus banned", + `definition user {} + + definition folder { + relation viewer: user + permission view = viewer + } + + definition document { + relation banned: user + relation folder: folder + permission view = folder.all(view) - banned + }`, + []*corev1.RelationTuple{ + tuple.MustParse("folder:folder1#viewer@user:tom"), + tuple.MustParse("folder:folder1#viewer@user:fred"), + tuple.MustParse("document:somedoc#folder@folder:folder1"), + tuple.MustParse("document:somedoc#banned@user:fred"), + tuple.MustParse("document:somedoc#banned@user:sarah"), + }, + ONR("document", "somedoc", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "tom", + }, + }, + }, + { + "recursive all arrow ", + `definition user {} + + definition folder { + relation parent: folder + relation owner: user + + permission view = parent.all(owner) + } + + definition document { + relation folder: folder + permission view = folder.all(view) + }`, + []*corev1.RelationTuple{ + tuple.MustParse("folder:root1#owner@user:tom"), + tuple.MustParse("folder:root1#owner@user:fred"), + tuple.MustParse("folder:root1#owner@user:sarah"), + tuple.MustParse("folder:root2#owner@user:fred"), + tuple.MustParse("folder:root2#owner@user:sarah"), + + tuple.MustParse("folder:child1#parent@folder:root1"), + tuple.MustParse("folder:child1#parent@folder:root2"), + + tuple.MustParse("folder:child2#parent@folder:root1"), + tuple.MustParse("folder:child2#parent@folder:root2"), + + tuple.MustParse("document:doc1#folder@folder:child1"), + tuple.MustParse("document:doc1#folder@folder:child2"), + }, + ONR("document", "doc1", "view"), + RR("user", "..."), + []*v1.FoundSubject{ + { + SubjectId: "fred", + }, + { + SubjectId: "sarah", + }, + }, + }, } for _, tc := range testCases { diff --git a/internal/graph/check.go b/internal/graph/check.go index 606d03ddc6..7432512a8f 100644 --- a/internal/graph/check.go +++ b/internal/graph/check.go @@ -484,11 +484,23 @@ func (cc *ConcurrentChecker) runSetOperation(ctx context.Context, crc currentReq case *core.SetOperation_Child_UsersetRewrite: return cc.checkUsersetRewrite(ctx, crc, child.UsersetRewrite) case *core.SetOperation_Child_TupleToUserset: - return cc.checkTupleToUserset(ctx, crc, child.TupleToUserset) + return checkTupleToUserset(ctx, cc, crc, child.TupleToUserset) + case *core.SetOperation_Child_FunctionedTupleToUserset: + switch child.FunctionedTupleToUserset.Function { + case core.FunctionedTupleToUserset_FUNCTION_ANY: + return checkTupleToUserset(ctx, cc, crc, child.FunctionedTupleToUserset) + + case core.FunctionedTupleToUserset_FUNCTION_ALL: + return checkIntersectionTupleToUserset(ctx, cc, crc, child.FunctionedTupleToUserset) + + default: + return checkResultError(spiceerrors.MustBugf("unknown userset function `%s`", child.FunctionedTupleToUserset.Function), emptyMetadata) + } + case *core.SetOperation_Child_XNil: return noMembers() default: - return checkResultError(fmt.Errorf("unknown set operation child `%T` in check", child), emptyMetadata) + return checkResultError(spiceerrors.MustBugf("unknown set operation child `%T` in check", child), emptyMetadata) } } @@ -576,8 +588,186 @@ func removeIndexFromSlice[T any](s []T, index int) []T { return append(cpy, s[index+1:]...) } -func (cc *ConcurrentChecker) checkTupleToUserset(ctx context.Context, crc currentRequestContext, ttu *core.TupleToUserset) CheckResult { - ctx, span := tracer.Start(ctx, ttu.Tupleset.Relation+"->"+ttu.ComputedUserset.Relation) +type relation interface { + GetRelation() string +} + +type ttu[T relation] interface { + GetComputedUserset() *core.ComputedUserset + GetTupleset() T +} + +type checkResultWithType struct { + CheckResult + + relationType *core.RelationReference +} + +func checkIntersectionTupleToUserset( + ctx context.Context, + cc *ConcurrentChecker, + crc currentRequestContext, + ttu *core.FunctionedTupleToUserset, +) CheckResult { + ctx, span := tracer.Start(ctx, ttu.GetTupleset().GetRelation()+"-(all)->"+ttu.GetComputedUserset().Relation) + defer span.End() + + // Query for the subjects over which to walk the TTU. + log.Ctx(ctx).Trace().Object("intersectionttu", crc.parentReq).Send() + ds := datastoremw.MustFromContext(ctx).SnapshotReader(crc.parentReq.Revision) + it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{ + OptionalResourceType: crc.parentReq.ResourceRelation.Namespace, + OptionalResourceIds: crc.filteredResourceIDs, + OptionalResourceRelation: ttu.GetTupleset().GetRelation(), + }) + if err != nil { + return checkResultError(NewCheckFailureErr(err), emptyMetadata) + } + defer it.Close() + + subjectsToDispatch := tuple.NewONRByTypeSet() + relationshipsBySubjectONR := mapz.NewMultiMap[string, *core.RelationTuple]() + subjectsByResourceID := mapz.NewMultiMap[string, *core.ObjectAndRelation]() + for tpl := it.Next(); tpl != nil; tpl = it.Next() { + if it.Err() != nil { + return checkResultError(NewCheckFailureErr(it.Err()), emptyMetadata) + } + + subjectsToDispatch.Add(tpl.Subject) + relationshipsBySubjectONR.Add(tuple.StringONR(tpl.Subject), tpl) + subjectsByResourceID.Add(tpl.ResourceAndRelation.ObjectId, tpl.Subject) + } + it.Close() + + // Convert the subjects into batched requests. + // To simplify the logic, +1 is added to account for the situation where + // the number of elements is less than the chunk size, and spare us some annoying code. + expectedNumberOfChunks := uint16(subjectsToDispatch.ValueLen())/crc.maxDispatchCount + 1 + toDispatch := make([]directDispatch, 0, expectedNumberOfChunks) + subjectsToDispatch.ForEachType(func(rr *core.RelationReference, resourceIds []string) { + chunkCount := 0.0 + slicez.ForEachChunk(resourceIds, crc.maxDispatchCount, func(resourceIdChunk []string) { + chunkCount++ + toDispatch = append(toDispatch, directDispatch{ + resourceType: rr, + resourceIds: resourceIdChunk, + }) + }) + dispatchChunkCountHistogram.Observe(chunkCount) + }) + + if subjectsToDispatch.IsEmpty() { + return noMembers() + } + + // Run the dispatch for all the chunks. Unlike a standard TTU, we do *not* perform mapping here, + // as we need to access the results on a per subject basis. Instead, we keep each result and map + // by the relation type of the dispatched subject. + chunkResults, err := run( + ctx, + currentRequestContext{ + parentReq: crc.parentReq, + filteredResourceIDs: crc.filteredResourceIDs, + resultsSetting: v1.DispatchCheckRequest_REQUIRE_ALL_RESULTS, + maxDispatchCount: crc.maxDispatchCount, + }, + toDispatch, + func(ctx context.Context, crc currentRequestContext, dd directDispatch) checkResultWithType { + childResult := cc.checkComputedUserset(ctx, crc, ttu.GetComputedUserset(), dd.resourceType, dd.resourceIds) + return checkResultWithType{ + CheckResult: childResult, + relationType: dd.resourceType, + } + }, + cc.concurrencyLimit, + ) + if err != nil { + return checkResultError(err, emptyMetadata) + } + + // Create a membership set per-subject-type, representing the membership for each of the dispatched subjects. + resultsByDispatchedSubject := map[string]*MembershipSet{} + combinedMetadata := emptyMetadata + for _, result := range chunkResults { + if result.Err != nil { + return checkResultError(result.Err, emptyMetadata) + } + + typeKey := tuple.StringRR(result.relationType) + if _, ok := resultsByDispatchedSubject[typeKey]; !ok { + resultsByDispatchedSubject[typeKey] = NewMembershipSet() + } + + resultsByDispatchedSubject[typeKey].UnionWith(result.Resp.ResultsByResourceId) + combinedMetadata = combineResponseMetadata(combinedMetadata, result.Resp.Metadata) + } + + // For each resource ID, check that there exist some sort of permission for *each* subject. If not, then the + // intersection for that resource fails. If all subjects have some sort of permission, then the resource ID is + // a member, perhaps caveated. + resourcesFound := NewMembershipSet() + for _, resourceID := range subjectsByResourceID.Keys() { + subjects, _ := subjectsByResourceID.Get(resourceID) + if len(subjects) == 0 { + return checkResultError(spiceerrors.MustBugf("no subjects found for resource ID %s", resourceID), emptyMetadata) + } + + hasAllSubjects := true + caveats := make([]*core.CaveatExpression, 0, len(subjects)) + + // Check each of the subjects found for the resource ID and ensure that membership (at least caveated) + // was found for each. If any are not found, then the resource ID is not a member. + // We also collect up the caveats for each subject, as they will be added to the final result. + for _, subject := range subjects { + subjectTypeKey := tuple.StringRR(&core.RelationReference{ + Namespace: subject.Namespace, + Relation: subject.Relation, + }) + + results, ok := resultsByDispatchedSubject[subjectTypeKey] + if !ok { + hasAllSubjects = false + break + } + + hasMembership, caveat := results.GetResourceID(subject.ObjectId) + if !hasMembership { + hasAllSubjects = false + break + } + + if caveat != nil { + caveats = append(caveats, caveat) + } + + // Add any caveats on the subject from the starting relationship(s) as well. + subjectKey := tuple.StringONR(subject) + tuples, _ := relationshipsBySubjectONR.Get(subjectKey) + for _, relationTuple := range tuples { + if relationTuple.Caveat != nil { + caveats = append(caveats, wrapCaveat(relationTuple.Caveat)) + } + } + } + + if !hasAllSubjects { + continue + } + + // Add the member to the membership set, with the caveats for each (if any). + resourcesFound.AddMemberWithOptionalCaveats(resourceID, caveats) + } + + return checkResultsForMembership(resourcesFound, combinedMetadata) +} + +func checkTupleToUserset[T relation]( + ctx context.Context, + cc *ConcurrentChecker, + crc currentRequestContext, + ttu ttu[T], +) CheckResult { + ctx, span := tracer.Start(ctx, ttu.GetTupleset().GetRelation()+"->"+ttu.GetComputedUserset().Relation) defer span.End() log.Ctx(ctx).Trace().Object("ttu", crc.parentReq).Send() @@ -585,7 +775,7 @@ func (cc *ConcurrentChecker) checkTupleToUserset(ctx context.Context, crc curren it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{ OptionalResourceType: crc.parentReq.ResourceRelation.Namespace, OptionalResourceIds: crc.filteredResourceIDs, - OptionalResourceRelation: ttu.Tupleset.Relation, + OptionalResourceRelation: ttu.GetTupleset().GetRelation(), }) if err != nil { return checkResultError(NewCheckFailureErr(err), emptyMetadata) @@ -607,7 +797,7 @@ func (cc *ConcurrentChecker) checkTupleToUserset(ctx context.Context, crc curren // Convert the subjects into batched requests. // To simplify the logic, +1 is added to account for the situation where // the number of elements is less than the chunk size, and spare us some annoying code. - expectedNumberOfChunks := subjectsToDispatch.ValueLen()/int(crc.maxDispatchCount) + 1 + expectedNumberOfChunks := uint16(subjectsToDispatch.ValueLen())/crc.maxDispatchCount + 1 toDispatch := make([]directDispatch, 0, expectedNumberOfChunks) subjectsToDispatch.ForEachType(func(rr *core.RelationReference, resourceIds []string) { chunkCount := 0.0 @@ -626,7 +816,7 @@ func (cc *ConcurrentChecker) checkTupleToUserset(ctx context.Context, crc curren crc, toDispatch, func(ctx context.Context, crc currentRequestContext, dd directDispatch) CheckResult { - childResult := cc.checkComputedUserset(ctx, crc, ttu.ComputedUserset, dd.resourceType, dd.resourceIds) + childResult := cc.checkComputedUserset(ctx, crc, ttu.GetComputedUserset(), dd.resourceType, dd.resourceIds) if childResult.Err != nil { return childResult } @@ -648,6 +838,42 @@ func withDistinctMetadata(result CheckResult) CheckResult { } } +// run runs all the children in parallel and returns the full set of results. +func run[T any, R withError]( + ctx context.Context, + crc currentRequestContext, + children []T, + handler func(ctx context.Context, crc currentRequestContext, child T) R, + concurrencyLimit uint16, +) ([]R, error) { + if len(children) == 0 { + return nil, nil + } + + if len(children) == 1 { + return []R{handler(ctx, crc, children[0])}, nil + } + + resultChan := make(chan R, len(children)) + childCtx, cancelFn := context.WithCancel(ctx) + dispatchAllAsync(childCtx, crc, children, handler, resultChan, concurrencyLimit) + defer cancelFn() + + results := make([]R, 0, len(children)) + for i := 0; i < len(children); i++ { + select { + case result := <-resultChan: + results = append(results, result) + + case <-ctx.Done(): + log.Ctx(ctx).Trace().Msg("anyCanceled") + return nil, ctx.Err() + } + } + + return results, nil +} + // union returns whether any one of the lazy checks pass, and is used for union. func union[T any]( ctx context.Context, @@ -832,12 +1058,16 @@ func difference[T any]( return checkResultsForMembership(membershipSet, responseMetadata) } -func dispatchAllAsync[T any]( +type withError interface { + ResultError() error +} + +func dispatchAllAsync[T any, R withError]( ctx context.Context, crc currentRequestContext, children []T, - handler func(ctx context.Context, crc currentRequestContext, child T) CheckResult, - resultChan chan<- CheckResult, + handler func(ctx context.Context, crc currentRequestContext, child T) R, + resultChan chan<- R, concurrencyLimit uint16, ) { tr := taskrunner.NewPreloadedTaskRunner(ctx, concurrencyLimit, len(children)) @@ -846,7 +1076,7 @@ func dispatchAllAsync[T any]( tr.Add(func(ctx context.Context) error { result := handler(ctx, crc, currentChild) resultChan <- result - return result.Err + return result.ResultError() }) } diff --git a/internal/graph/expand.go b/internal/graph/expand.go index 99924c1d42..937486d921 100644 --- a/internal/graph/expand.go +++ b/internal/graph/expand.go @@ -3,7 +3,6 @@ package graph import ( "context" "errors" - "fmt" "github.com/authzed/spicedb/internal/caveats" @@ -191,11 +190,22 @@ func (ce *ConcurrentExpander) expandSetOperation(ctx context.Context, req Valida case *core.SetOperation_Child_UsersetRewrite: requests = append(requests, ce.expandUsersetRewrite(ctx, req, child.UsersetRewrite)) case *core.SetOperation_Child_TupleToUserset: - requests = append(requests, ce.expandTupleToUserset(ctx, req, child.TupleToUserset)) + requests = append(requests, expandTupleToUserset(ctx, ce, req, child.TupleToUserset, expandAny)) + case *core.SetOperation_Child_FunctionedTupleToUserset: + switch child.FunctionedTupleToUserset.Function { + case core.FunctionedTupleToUserset_FUNCTION_ANY: + requests = append(requests, expandTupleToUserset(ctx, ce, req, child.FunctionedTupleToUserset, expandAny)) + + case core.FunctionedTupleToUserset_FUNCTION_ALL: + requests = append(requests, expandTupleToUserset(ctx, ce, req, child.FunctionedTupleToUserset, expandAll)) + + default: + return expandError(spiceerrors.MustBugf("unknown function `%s` in expand", child.FunctionedTupleToUserset.Function)) + } case *core.SetOperation_Child_XNil: requests = append(requests, emptyExpansion(req.ResourceAndRelation)) default: - return expandError(fmt.Errorf("unknown set operation child `%T` in expand", child)) + return expandError(spiceerrors.MustBugf("unknown set operation child `%T` in expand", child)) } } return func(ctx context.Context, resultChan chan<- ExpandResult) { @@ -253,13 +263,21 @@ func (ce *ConcurrentExpander) expandComputedUserset(ctx context.Context, req Val }) } -func (ce *ConcurrentExpander) expandTupleToUserset(_ context.Context, req ValidatedExpandRequest, ttu *core.TupleToUserset) ReduceableExpandFunc { +type expandFunc func(ctx context.Context, start *core.ObjectAndRelation, requests []ReduceableExpandFunc) ExpandResult + +func expandTupleToUserset[T relation]( + _ context.Context, + ce *ConcurrentExpander, + req ValidatedExpandRequest, + ttu ttu[T], + expandFunc expandFunc, +) ReduceableExpandFunc { return func(ctx context.Context, resultChan chan<- ExpandResult) { ds := datastoremw.MustFromContext(ctx).SnapshotReader(req.Revision) it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{ OptionalResourceType: req.ResourceAndRelation.Namespace, OptionalResourceIds: []string{req.ResourceAndRelation.ObjectId}, - OptionalResourceRelation: ttu.Tupleset.Relation, + OptionalResourceRelation: ttu.GetTupleset().GetRelation(), }) if err != nil { resultChan <- expandResultError(NewExpansionFailureErr(err), emptyMetadata) @@ -274,12 +292,12 @@ func (ce *ConcurrentExpander) expandTupleToUserset(_ context.Context, req Valida return } - toDispatch := ce.expandComputedUserset(ctx, req, ttu.ComputedUserset, tpl) + toDispatch := ce.expandComputedUserset(ctx, req, ttu.GetComputedUserset(), tpl) requestsToDispatch = append(requestsToDispatch, decorateWithCaveatIfNecessary(toDispatch, caveats.CaveatAsExpr(tpl.Caveat))) } it.Close() - resultChan <- expandAny(ctx, req.ResourceAndRelation, requestsToDispatch) + resultChan <- expandFunc(ctx, req.ResourceAndRelation, requestsToDispatch) } } diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 232d8641c6..e05074f89d 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -38,12 +38,20 @@ type CheckResult struct { Err error } +func (cr CheckResult) ResultError() error { + return cr.Err +} + // ExpandResult is the data that is returned by a single expand or sub-expand. type ExpandResult struct { Resp *v1.DispatchExpandResponse Err error } +func (er ExpandResult) ResultError() error { + return er.Err +} + // ReduceableExpandFunc is a function that can be bound to a execution context. type ReduceableExpandFunc func(ctx context.Context, resultChan chan<- ExpandResult) diff --git a/internal/graph/lookupsubjects.go b/internal/graph/lookupsubjects.go index e7c2663018..510b82ce52 100644 --- a/internal/graph/lookupsubjects.go +++ b/internal/graph/lookupsubjects.go @@ -4,19 +4,24 @@ import ( "context" "errors" "fmt" + "sync" "golang.org/x/sync/errgroup" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/authzed/spicedb/internal/datasets" "github.com/authzed/spicedb/internal/dispatch" log "github.com/authzed/spicedb/internal/logging" datastoremw "github.com/authzed/spicedb/internal/middleware/datastore" "github.com/authzed/spicedb/internal/namespace" + "github.com/authzed/spicedb/internal/taskrunner" "github.com/authzed/spicedb/pkg/datastore" "github.com/authzed/spicedb/pkg/genutil/mapz" "github.com/authzed/spicedb/pkg/genutil/slicez" core "github.com/authzed/spicedb/pkg/proto/core/v1" v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" + "github.com/authzed/spicedb/pkg/spiceerrors" "github.com/authzed/spicedb/pkg/tuple" ) @@ -188,16 +193,199 @@ func (cl *ConcurrentLookupSubjects) lookupViaComputed( }, stream) } -func (cl *ConcurrentLookupSubjects) lookupViaTupleToUserset( +type resourceDispatchTracker struct { + ctx context.Context + cancelDispatch context.CancelFunc + resourceID string + + subjectsSet datasets.SubjectSet + metadata *v1.ResponseMeta + + isFirstUpdate bool + wasCanceled bool + + lock sync.Mutex +} + +func lookupViaIntersectionTupleToUserset( + ctx context.Context, + cl *ConcurrentLookupSubjects, + parentRequest ValidatedLookupSubjectsRequest, + parentStream dispatch.LookupSubjectsStream, + ttu *core.FunctionedTupleToUserset, +) error { + ds := datastoremw.MustFromContext(ctx).SnapshotReader(parentRequest.Revision) + it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{ + OptionalResourceType: parentRequest.ResourceRelation.Namespace, + OptionalResourceRelation: ttu.GetTupleset().GetRelation(), + OptionalResourceIds: parentRequest.ResourceIds, + }) + if err != nil { + return err + } + defer it.Close() + + // TODO(jschorr): Find a means of doing this without dispatching per subject, per resource. Perhaps + // there is a way we can still dispatch to all the subjects at once, and then intersect the results + // afterwards. + resourceDispatchTrackerByResourceID := make(map[string]*resourceDispatchTracker) + + cancelCtx, checkCancel := context.WithCancel(ctx) + defer checkCancel() + + // For each found tuple, dispatch a lookup subjects request and collect its results. + // We need to intersect between *all* the found subjects for each resource ID. + var ttuCaveat *core.CaveatExpression + taskrunner := taskrunner.NewPreloadedTaskRunner(cancelCtx, cl.concurrencyLimit, 1) + for tpl := it.Next(); tpl != nil; tpl = it.Next() { + if it.Err() != nil { + return it.Err() + } + + // If the relationship has a caveat, add it to the overall TTU caveat. Since this is an intersection + // of *all* branches, the caveat will be applied to all found subjects, so this is a safe approach. + if tpl.Caveat != nil { + ttuCaveat = caveatAnd(ttuCaveat, wrapCaveat(tpl.Caveat)) + } + + if err := namespace.CheckNamespaceAndRelation(ctx, tpl.Subject.Namespace, ttu.GetComputedUserset().Relation, false, ds); err != nil { + if !errors.As(err, &namespace.ErrRelationNotFound{}) { + return err + } + + continue + } + + // Create a data structure to track the intersection of subjects for the particular resource. If the resource's subject set + // ends up empty anywhere along the way, the dispatches for *that resource* will be canceled early. + resourceID := tpl.ResourceAndRelation.ObjectId + dispatchInfoForResource, ok := resourceDispatchTrackerByResourceID[resourceID] + if !ok { + dispatchCtx, cancelDispatch := context.WithCancel(cancelCtx) + dispatchInfoForResource = &resourceDispatchTracker{ + ctx: dispatchCtx, + cancelDispatch: cancelDispatch, + resourceID: resourceID, + subjectsSet: datasets.NewSubjectSet(), + metadata: emptyMetadata, + isFirstUpdate: true, + lock: sync.Mutex{}, + } + resourceDispatchTrackerByResourceID[resourceID] = dispatchInfoForResource + } + + tpl := tpl + taskrunner.Add(func(ctx context.Context) error { + // Collect all results for this branch of the resource ID. + // TODO(jschorr): once LS has cursoring (and thus, ordering), we can move to not collecting everything up before intersecting + // for this branch of the resource ID. + collectingStream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupSubjectsResponse](dispatchInfoForResource.ctx) + err := cl.d.DispatchLookupSubjects(&v1.DispatchLookupSubjectsRequest{ + ResourceRelation: &core.RelationReference{ + Namespace: tpl.Subject.Namespace, + Relation: ttu.GetComputedUserset().Relation, + }, + ResourceIds: []string{tpl.Subject.ObjectId}, + SubjectRelation: parentRequest.SubjectRelation, + Metadata: &v1.ResolverMeta{ + AtRevision: parentRequest.Revision.String(), + DepthRemaining: parentRequest.Metadata.DepthRemaining - 1, + }, + }, collectingStream) + if err != nil { + // Check if the dispatches for the resource were canceled, and if so, return nil to stop the task. + dispatchInfoForResource.lock.Lock() + wasCanceled := dispatchInfoForResource.wasCanceled + dispatchInfoForResource.lock.Unlock() + + if wasCanceled { + if errors.Is(err, context.Canceled) { + return nil + } + + errStatus, ok := status.FromError(err) + if ok && errStatus.Code() == codes.Canceled { + return nil + } + } + + return err + } + + // Collect the results into a subject set. + results := datasets.NewSubjectSet() + collectedMetadata := emptyMetadata + for _, result := range collectingStream.Results() { + collectedMetadata = combineResponseMetadata(collectedMetadata, result.Metadata) + for _, foundSubjects := range result.FoundSubjectsByResourceId { + if err := results.UnionWith(foundSubjects.FoundSubjects); err != nil { + return fmt.Errorf("failed to UnionWith under lookupSubjectsIntersection: %w", err) + } + } + } + + dispatchInfoForResource.lock.Lock() + defer dispatchInfoForResource.lock.Unlock() + + dispatchInfoForResource.metadata = combineResponseMetadata(dispatchInfoForResource.metadata, collectedMetadata) + + // If the first update for the resource, set the subjects set to the results. + if dispatchInfoForResource.isFirstUpdate { + dispatchInfoForResource.isFirstUpdate = false + dispatchInfoForResource.subjectsSet = results + } else { + // Otherwise, intersect the results with the existing subjects set. + err := dispatchInfoForResource.subjectsSet.IntersectionDifference(results) + if err != nil { + return err + } + } + + // If the subjects set is empty, cancel the dispatch for any further results for this resource ID. + if dispatchInfoForResource.subjectsSet.IsEmpty() { + dispatchInfoForResource.wasCanceled = true + dispatchInfoForResource.cancelDispatch() + } + + return nil + }) + } + it.Close() + + // Wait for all dispatched operations to complete. + if err := taskrunner.StartAndWait(); err != nil { + return err + } + + // For each resource ID, intersect the found subjects from each stream. + metadata := emptyMetadata + currentSubjectsByResourceID := map[string]*v1.FoundSubjects{} + + for incomingResourceID, tracker := range resourceDispatchTrackerByResourceID { + currentSubjects := tracker.subjectsSet + currentSubjects = currentSubjects.WithParentCaveatExpression(ttuCaveat) + currentSubjectsByResourceID[incomingResourceID] = currentSubjects.AsFoundSubjects() + + metadata = combineResponseMetadata(metadata, tracker.metadata) + } + + return parentStream.Publish(&v1.DispatchLookupSubjectsResponse{ + FoundSubjectsByResourceId: currentSubjectsByResourceID, + Metadata: metadata, + }) +} + +func lookupViaTupleToUserset[T relation]( ctx context.Context, + cl *ConcurrentLookupSubjects, parentRequest ValidatedLookupSubjectsRequest, parentStream dispatch.LookupSubjectsStream, - ttu *core.TupleToUserset, + ttu ttu[T], ) error { ds := datastoremw.MustFromContext(ctx).SnapshotReader(parentRequest.Revision) it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{ OptionalResourceType: parentRequest.ResourceRelation.Namespace, - OptionalResourceRelation: ttu.Tupleset.Relation, + OptionalResourceRelation: ttu.GetTupleset().GetRelation(), OptionalResourceIds: parentRequest.ResourceIds, }) if err != nil { @@ -223,14 +411,14 @@ func (cl *ConcurrentLookupSubjects) lookupViaTupleToUserset( relationshipsBySubjectONR.Add(tuple.StringONR(&core.ObjectAndRelation{ Namespace: tpl.Subject.Namespace, ObjectId: tpl.Subject.ObjectId, - Relation: ttu.ComputedUserset.Relation, + Relation: ttu.GetComputedUserset().Relation, }), tpl) } it.Close() // Map the found subject types by the computed userset relation, so that we dispatch to it. toDispatchByComputedRelationType, err := toDispatchByTuplesetType.Map(func(resourceType *core.RelationReference) (*core.RelationReference, error) { - if err := namespace.CheckNamespaceAndRelation(ctx, resourceType.Namespace, ttu.ComputedUserset.Relation, false, ds); err != nil { + if err := namespace.CheckNamespaceAndRelation(ctx, resourceType.Namespace, ttu.GetComputedUserset().Relation, false, ds); err != nil { if errors.As(err, &namespace.ErrRelationNotFound{}) { return nil, nil } @@ -240,7 +428,7 @@ func (cl *ConcurrentLookupSubjects) lookupViaTupleToUserset( return &core.RelationReference{ Namespace: resourceType.Namespace, - Relation: ttu.ComputedUserset.Relation, + Relation: ttu.GetComputedUserset().Relation, }, nil }) if err != nil { @@ -302,15 +490,31 @@ func (cl *ConcurrentLookupSubjects) lookupSetOperation( case *core.SetOperation_Child_TupleToUserset: g.Go(func() error { - return cl.lookupViaTupleToUserset(subCtx, req, stream, child.TupleToUserset) + return lookupViaTupleToUserset(subCtx, cl, req, stream, child.TupleToUserset) }) + case *core.SetOperation_Child_FunctionedTupleToUserset: + switch child.FunctionedTupleToUserset.Function { + case core.FunctionedTupleToUserset_FUNCTION_ANY: + g.Go(func() error { + return lookupViaTupleToUserset(subCtx, cl, req, stream, child.FunctionedTupleToUserset) + }) + + case core.FunctionedTupleToUserset_FUNCTION_ALL: + g.Go(func() error { + return lookupViaIntersectionTupleToUserset(subCtx, cl, req, stream, child.FunctionedTupleToUserset) + }) + + default: + return spiceerrors.MustBugf("unknown function in lookup subjects: %v", child.FunctionedTupleToUserset.Function) + } + case *core.SetOperation_Child_XNil: // Purposely do nothing. continue default: - return fmt.Errorf("unknown set operation child `%T` in expand", child) + return spiceerrors.MustBugf("unknown set operation child `%T` in lookup subjects", child) } } diff --git a/internal/graph/membershipset.go b/internal/graph/membershipset.go index 24665de9e9..7ec263e0e1 100644 --- a/internal/graph/membershipset.go +++ b/internal/graph/membershipset.go @@ -62,6 +62,25 @@ func (ms *MembershipSet) AddMemberViaRelationship( ms.addMember(resourceID, intersection) } +// AddMemberWithOptionalCaveats adds the given resource ID as a member with the optional caveats combined +// via intersection. +func (ms *MembershipSet) AddMemberWithOptionalCaveats( + resourceID string, + caveats []*core.CaveatExpression, +) { + if len(caveats) == 0 { + ms.addMember(resourceID, nil) + return + } + + intersection := caveats[0] + for _, caveat := range caveats[1:] { + intersection = caveatAnd(intersection, caveat) + } + + ms.addMember(resourceID, intersection) +} + func (ms *MembershipSet) addMember(resourceID string, caveatExpr *core.CaveatExpression) { existing, ok := ms.membersByID[resourceID] if !ok { @@ -153,6 +172,17 @@ func (ms *MembershipSet) HasConcreteResourceID(resourceID string) bool { return ok && found == nil } +// GetResourceID returns a bool indicating whether the resource is found in the set and the +// associated caveat expression, if any. +func (ms *MembershipSet) GetResourceID(resourceID string) (bool, *core.CaveatExpression) { + if ms == nil { + return false, nil + } + + caveat, ok := ms.membersByID[resourceID] + return ok, caveat +} + // Size returns the number of elements in the membership set. func (ms *MembershipSet) Size() int { if ms == nil { diff --git a/internal/namespace/canonicalization.go b/internal/namespace/canonicalization.go index b1b142c759..5de8ff0897 100644 --- a/internal/namespace/canonicalization.go +++ b/internal/namespace/canonicalization.go @@ -37,7 +37,7 @@ const computedKeyPrefix = "%" // expressions in the namespace: // // definition somenamespace { -// relation first: ... +// relation first: ... // ^ index 0 // relation second: ... // ^ index 1 @@ -155,6 +155,28 @@ func convertToBdd(relation *core.Relation, bdd *rudd.BDD, so *core.SetOperation, values = append(values, builder(index, arrowIndex)) + case *core.SetOperation_Child_FunctionedTupleToUserset: + switch child.FunctionedTupleToUserset.Function { + case core.FunctionedTupleToUserset_FUNCTION_ANY: + arrowIndex, err := varMap.GetArrow(child.FunctionedTupleToUserset.Tupleset.Relation, child.FunctionedTupleToUserset.ComputedUserset.Relation) + if err != nil { + return nil, err + } + + values = append(values, builder(index, arrowIndex)) + + case core.FunctionedTupleToUserset_FUNCTION_ALL: + arrowIndex, err := varMap.GetIntersectionArrow(child.FunctionedTupleToUserset.Tupleset.Relation, child.FunctionedTupleToUserset.ComputedUserset.Relation) + if err != nil { + return nil, err + } + + values = append(values, builder(index, arrowIndex)) + + default: + return nil, spiceerrors.MustBugf("unknown function %v", child.FunctionedTupleToUserset.Function) + } + case *core.SetOperation_Child_XNil: values = append(values, builder(index, varMap.Nil())) @@ -179,6 +201,15 @@ func (bvm bddVarMap) GetArrow(tuplesetName string, relName string) (int, error) return index, nil } +func (bvm bddVarMap) GetIntersectionArrow(tuplesetName string, relName string) (int, error) { + key := tuplesetName + "-(all)->" + relName + index, ok := bvm.varMap[key] + if !ok { + return -1, spiceerrors.MustBugf("missing intersection arrow key %s in varMap", key) + } + return index, nil +} + func (bvm bddVarMap) Nil() int { return len(bvm.varMap) } @@ -213,15 +244,32 @@ func buildBddVarMap(relations []*core.Relation, aliasMap map[string]string) (bdd continue } - _, err := graph.WalkRewrite(rewrite, func(childOneof *core.SetOperation_Child) interface{} { + _, err := graph.WalkRewrite(rewrite, func(childOneof *core.SetOperation_Child) (interface{}, error) { switch child := childOneof.ChildType.(type) { case *core.SetOperation_Child_TupleToUserset: key := child.TupleToUserset.Tupleset.Relation + "->" + child.TupleToUserset.ComputedUserset.Relation + if _, ok := varMap[key]; !ok { + varMap[key] = len(varMap) + } + case *core.SetOperation_Child_FunctionedTupleToUserset: + key := child.FunctionedTupleToUserset.Tupleset.Relation + "->" + child.FunctionedTupleToUserset.ComputedUserset.Relation + + switch child.FunctionedTupleToUserset.Function { + case core.FunctionedTupleToUserset_FUNCTION_ANY: + // Use the key. + + case core.FunctionedTupleToUserset_FUNCTION_ALL: + key = child.FunctionedTupleToUserset.Tupleset.Relation + "-(all)->" + child.FunctionedTupleToUserset.ComputedUserset.Relation + + default: + return nil, spiceerrors.MustBugf("unknown function %v", child.FunctionedTupleToUserset.Function) + } + if _, ok := varMap[key]; !ok { varMap[key] = len(varMap) } } - return nil + return nil, nil }) if err != nil { return bddVarMap{}, err diff --git a/internal/namespace/canonicalization_test.go b/internal/namespace/canonicalization_test.go index 5fc84e26d8..3250faf2e1 100644 --- a/internal/namespace/canonicalization_test.go +++ b/internal/namespace/canonicalization_test.go @@ -375,6 +375,49 @@ func TestCanonicalization(t *testing.T) { "second": computedKeyPrefix + "bfc8d945d7030961", }, }, + { + "canonicalization with functioned arrow expressions", + ns.Namespace( + "document", + ns.MustRelation("owner", nil), + ns.MustRelation("viewer", nil), + ns.MustRelation("first", ns.Union( + ns.TupleToUserset("owner", "something"), + )), + ns.MustRelation("second", ns.Union( + ns.TupleToUserset("owner", "something"), + )), + ns.MustRelation("difftuple", ns.Union( + ns.TupleToUserset("viewer", "something"), + )), + ns.MustRelation("third", ns.Union( + ns.MustFunctionedTupleToUserset("owner", "any", "something"), + )), + ns.MustRelation("thirdwithall", ns.Union( + ns.MustFunctionedTupleToUserset("owner", "all", "something"), + )), + ns.MustRelation("allplusanother", ns.Union( + ns.MustFunctionedTupleToUserset("owner", "all", "something"), + ns.ComputedUserset("owner"), + )), + ns.MustRelation("anotherplusall", ns.Union( + ns.ComputedUserset("owner"), + ns.MustFunctionedTupleToUserset("owner", "all", "something"), + )), + ), + "", + map[string]string{ + "owner": "owner", + "viewer": "viewer", + "first": computedKeyPrefix + "9fd2b03cabeb2e42", + "second": computedKeyPrefix + "9fd2b03cabeb2e42", + "third": computedKeyPrefix + "9fd2b03cabeb2e42", + "thirdwithall": computedKeyPrefix + "eafa2f3f2d970680", + "difftuple": computedKeyPrefix + "dddc650e89a7bf1a", + "allplusanother": computedKeyPrefix + "8b68ba1711b32ca4", + "anotherplusall": computedKeyPrefix + "8b68ba1711b32ca4", + }, + }, } for _, tc := range testCases { diff --git a/internal/services/integrationtesting/testconfigs/caveatedintersectionarrow.yaml b/internal/services/integrationtesting/testconfigs/caveatedintersectionarrow.yaml new file mode 100644 index 0000000000..1beb928621 --- /dev/null +++ b/internal/services/integrationtesting/testconfigs/caveatedintersectionarrow.yaml @@ -0,0 +1,55 @@ +--- +schema: |+ + definition user {} + + definition team { + relation direct_member: user with membercaveat + permission member = direct_member + } + + caveat membercaveat(memberparam int) { + memberparam >= 42 + } + + caveat teamcaveat(teamparam int) { + teamparam < 42 + } + + definition resource { + relation team: team with teamcaveat + permission view_by_all = team.all(member) + } + +relationships: |- + team:first#direct_member@user:tom[membercaveat] + resource:firstresource#team@team:first[teamcaveat] + + team:team2-1#direct_member@user:tom[membercaveat:{"memberparam": 43}] + team:team2-2#direct_member@user:tom[membercaveat:{"memberparam": 44}] + resource:positivecaveated#team@team:team2-1[teamcaveat:{"teamparam": 1}] + resource:positivecaveated#team@team:team2-2[teamcaveat:{"teamparam": 2}] + + team:team3-1#direct_member@user:tom[membercaveat:{"memberparam": 43}] + team:team3-2#direct_member@user:tom[membercaveat:{"memberparam": 44}] + resource:negativeteam#team@team:team3-1[teamcaveat:{"teamparam": 100}] + resource:negativeteam#team@team:team3-2[teamcaveat:{"teamparam": 2}] + + team:team4-1#direct_member@user:tom[membercaveat:{"memberparam": 3}] + team:team4-2#direct_member@user:tom[membercaveat:{"memberparam": 44}] + resource:negativemember#team@team:team4-1[teamcaveat:{"teamparam": 1}] + resource:negativemember#team@team:team4-2[teamcaveat:{"teamparam": 2}] + +assertions: + assertTrue: + - 'resource:firstresource#view_by_all@user:tom with {"memberparam": 42, "teamparam": 41}' + - "resource:positivecaveated#view_by_all@user:tom" + assertCaveated: + - "resource:firstresource#view_by_all@user:tom" + - 'resource:firstresource#view_by_all@user:tom with {"memberparam": 42}' + - 'resource:firstresource#view_by_all@user:tom with {"teamparam": 41}' + assertFalse: + - 'resource:firstresource#view_by_all@user:tom with {"memberparam": 1, "teamparam": 41}' + - 'resource:firstresource#view_by_all@user:tom with {"memberparam": 42, "teamparam": 100}' + - 'resource:firstresource#view_by_all@user:tom with {"memberparam": 1, "teamparam": 100}' + - "resource:negativeteam#view_by_all@user:tom" + - "resource:negativemember#view_by_all@user:tom" diff --git a/internal/services/integrationtesting/testconfigs/intersectionarrow.yaml b/internal/services/integrationtesting/testconfigs/intersectionarrow.yaml new file mode 100644 index 0000000000..446d63c401 --- /dev/null +++ b/internal/services/integrationtesting/testconfigs/intersectionarrow.yaml @@ -0,0 +1,49 @@ +--- +schema: |+ + definition user {} + + definition team { + relation direct_member: user + permission member = direct_member + } + + definition resource { + relation team: team + permission view_by_all = team.all(member) + permission view_by_any = team.any(member) + } + +relationships: |- + team:first#direct_member@user:tom + team:first#direct_member@user:fred + team:first#direct_member@user:sarah + team:second#direct_member@user:fred + team:second#direct_member@user:sarah + team:third#direct_member@user:sarah + resource:oneteam#team@team:first + resource:twoteams#team@team:first + resource:twoteams#team@team:second + resource:threeteams#team@team:first + resource:threeteams#team@team:second + resource:threeteams#team@team:third + +assertions: + assertTrue: + - "resource:oneteam#view_by_all@user:tom" + - "resource:oneteam#view_by_all@user:fred" + - "resource:oneteam#view_by_all@user:sarah" + - "resource:twoteams#view_by_all@user:fred" + - "resource:threeteams#view_by_all@user:sarah" + - "resource:oneteam#view_by_any@user:tom" + - "resource:oneteam#view_by_any@user:fred" + - "resource:oneteam#view_by_any@user:sarah" + - "resource:twoteams#view_by_any@user:tom" + - "resource:twoteams#view_by_any@user:fred" + - "resource:twoteams#view_by_any@user:sarah" + - "resource:threeteams#view_by_any@user:tom" + - "resource:threeteams#view_by_any@user:fred" + - "resource:threeteams#view_by_any@user:sarah" + assertFalse: + - "resource:twoteams#view_by_all@user:tom" + - "resource:threeteams#view_by_all@user:tom" + - "resource:threeteams#view_by_all@user:fred" diff --git a/internal/testutil/subjects.go b/internal/testutil/subjects.go index 9e6694b72f..be6b51a00f 100644 --- a/internal/testutil/subjects.go +++ b/internal/testutil/subjects.go @@ -70,7 +70,7 @@ func Wildcard(exclusions ...string) *v1.FoundSubject { func RequireEquivalentSets(t *testing.T, expected []*v1.FoundSubject, found []*v1.FoundSubject) { t.Helper() err := CheckEquivalentSets(expected, found) - require.NoError(t, err, "found different subject sets: %v", err) + require.NoError(t, err, "found different subject sets: %v \n %v", err, found) } // RequireExpectedSubject requires that the given expected and produced subjects match. diff --git a/pkg/graph/walker.go b/pkg/graph/walker.go index 2a6a9c6502..3ae8b6bddf 100644 --- a/pkg/graph/walker.go +++ b/pkg/graph/walker.go @@ -7,7 +7,7 @@ import ( // WalkHandler is a function invoked for each node in the rewrite tree. If it returns non-nil, // that value is returned from the walk. Otherwise, the walk continues. -type WalkHandler func(childOneof *core.SetOperation_Child) interface{} +type WalkHandler func(childOneof *core.SetOperation_Child) (interface{}, error) // WalkRewrite walks a userset rewrite tree, invoking the handler found on each node of the tree // until the handler returns a non-nil value, which is in turn returned from this function. Returns @@ -32,12 +32,12 @@ func WalkRewrite(rewrite *core.UsersetRewrite, handler WalkHandler) (interface{} // HasThis returns true if there exists a `_this` node anywhere within the given rewrite. If // the rewrite is nil, returns false. func HasThis(rewrite *core.UsersetRewrite) (bool, error) { - result, err := WalkRewrite(rewrite, func(childOneof *core.SetOperation_Child) interface{} { + result, err := WalkRewrite(rewrite, func(childOneof *core.SetOperation_Child) (interface{}, error) { switch childOneof.ChildType.(type) { case *core.SetOperation_Child_XThis: - return true + return true, nil default: - return nil + return nil, nil } }) return result != nil && result.(bool), err @@ -45,7 +45,11 @@ func HasThis(rewrite *core.UsersetRewrite) (bool, error) { func walkRewriteChildren(so *core.SetOperation, handler WalkHandler) (interface{}, error) { for _, childOneof := range so.Child { - vle := handler(childOneof) + vle, err := handler(childOneof) + if err != nil { + return nil, err + } + if vle != nil { return vle, nil } diff --git a/pkg/namespace/builder.go b/pkg/namespace/builder.go index 66e4755606..03c81c97a9 100644 --- a/pkg/namespace/builder.go +++ b/pkg/namespace/builder.go @@ -272,7 +272,7 @@ func MustFunctionedTupleToUserset(tuplesetRelation, functionName, usersetRelatio switch functionName { case "any": - function = core.FunctionedTupleToUserset_FUNCTION_ANY + // already set to any case "all": function = core.FunctionedTupleToUserset_FUNCTION_ALL diff --git a/pkg/schemadsl/parser/parser_impl.go b/pkg/schemadsl/parser/parser_impl.go index 318e2be7d6..76f2bf3839 100644 --- a/pkg/schemadsl/parser/parser_impl.go +++ b/pkg/schemadsl/parser/parser_impl.go @@ -187,25 +187,6 @@ func (p *sourceParser) consumeKeyword(keyword string) bool { return true } -// consumeKeywords consumes an expected keyword token(s) or adds an error node. -func (p *sourceParser) consumeKeywords(keywords ...string) (string, bool) { - keyword, ok := p.tryConsumeKeywords(keywords...) - if !ok { - p.emitErrorf("Expected one of: %v, found: %v", keywords, p.currentToken.Kind) - } - return keyword, ok -} - -// tryConsumeKeywords consumes an expected keyword token(s) or adds an error node. -func (p *sourceParser) tryConsumeKeywords(keywords ...string) (string, bool) { - for _, keyword := range keywords { - if p.tryConsumeKeyword(keyword) { - return keyword, true - } - } - return "", false -} - // tryConsumeKeyword attempts to consume an expected keyword token. func (p *sourceParser) tryConsumeKeyword(keyword string) bool { if !p.isKeyword(keyword) { diff --git a/pkg/typesystem/reachabilitygraph_test.go b/pkg/typesystem/reachabilitygraph_test.go index 1dfaf2de92..bd0d9d1bee 100644 --- a/pkg/typesystem/reachabilitygraph_test.go +++ b/pkg/typesystem/reachabilitygraph_test.go @@ -115,6 +115,38 @@ func TestRelationsEncounteredForSubject(t *testing.T) { "admin", []string{"document#view"}, }, + { + "simple any arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + permission view = org.any(admin) + }`, + "organization", + "admin", + []string{"document#view"}, + }, + { + "simple all arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + permission view = org.all(admin) + }`, + "organization", + "admin", + []string{"document#view"}, + }, { "complex schema", `definition user {} @@ -321,6 +353,44 @@ func TestRelationsEncounteredForResource(t *testing.T) { "view", []string{"document#viewer", "document#owner", "document#org", "document#view", "organization#admin"}, }, + { + "permission with any arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + relation viewer: user + relation owner: user + + permission view = viewer + owner + org.any(admin) + }`, + "document", + "view", + []string{"document#viewer", "document#owner", "document#org", "document#view", "organization#admin"}, + }, + { + "permission with all arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + relation viewer: user + relation owner: user + + permission view = viewer + owner + org.all(admin) + }`, + "document", + "view", + []string{"document#viewer", "document#owner", "document#org", "document#view", "organization#admin"}, + }, { "permission with subrelation", `definition user {} @@ -683,6 +753,60 @@ func TestReachabilityGraph(t *testing.T) { rrt("organization", "admin", true), }, }, + { + "permission with any arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + relation viewer: user + relation owner: user + permission view = viewer + owner + org.any(admin) + }`, + rr("document", "view"), + rr("user", "..."), + []rrtStruct{ + rrt("document", "owner", true), + rrt("document", "viewer", true), + rrt("organization", "admin", true), + }, + []rrtStruct{ + rrt("document", "owner", true), + rrt("document", "viewer", true), + rrt("organization", "admin", true), + }, + }, + { + "permission with all arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + relation viewer: user + relation owner: user + permission view = viewer + owner + org.all(admin) + }`, + rr("document", "view"), + rr("user", "..."), + []rrtStruct{ + rrt("document", "owner", true), + rrt("document", "viewer", true), + rrt("organization", "admin", true), + }, + []rrtStruct{ + rrt("document", "owner", true), + rrt("document", "viewer", true), + rrt("organization", "admin", true), + }, + }, { "permission with multi-level arrows", `definition user {} @@ -1013,6 +1137,48 @@ func TestReachabilityGraph(t *testing.T) { rrt("organization", "viewer", true), }, }, + { + "optimized reachability with any arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + permission view = org.any(admin) + }`, + rr("document", "view"), + rr("organization", "admin"), + []rrtStruct{ + rrt("document", "view", true), + }, + []rrtStruct{ + rrt("document", "view", true), + }, + }, + { + "optimized reachability with all arrow", + `definition user {} + + definition organization { + relation admin: user + } + + definition document { + relation org: organization + permission view = org.all(admin) + }`, + rr("document", "view"), + rr("organization", "admin"), + []rrtStruct{ + rrt("document", "view", false), + }, + []rrtStruct{ + rrt("document", "view", false), + }, + }, } for _, tc := range testCases { diff --git a/pkg/typesystem/reachabilitygraphbuilder.go b/pkg/typesystem/reachabilitygraphbuilder.go index 97f5657bf9..1abbf5297f 100644 --- a/pkg/typesystem/reachabilitygraphbuilder.go +++ b/pkg/typesystem/reachabilitygraphbuilder.go @@ -98,59 +98,29 @@ func computeRewriteOpReachability(ctx context.Context, children []*core.SetOpera case *core.SetOperation_Child_TupleToUserset: tuplesetRelation := child.TupleToUserset.Tupleset.Relation - directRelationTypes, err := ts.AllowedDirectRelationsAndWildcards(tuplesetRelation) - if err != nil { + computedUsersetRelation := child.TupleToUserset.ComputedUserset.Relation + if err := computeTTUReachability(ctx, graph, tuplesetRelation, computedUsersetRelation, operationResultState, rr, ts); err != nil { return err } - computedUsersetRelation := child.TupleToUserset.ComputedUserset.Relation - for _, allowedRelationType := range directRelationTypes { - // For each namespace allowed to be found on the right hand side of the - // tupleset relation, include the *computed userset* relation as an entrypoint. - // - // For example, given a schema: - // - // ``` - // definition user {} - // - // definition parent1 { - // relation somerel: user - // } - // - // definition parent2 { - // relation somerel: user - // } - // - // definition child { - // relation parent: parent1 | parent2 - // permission someperm = parent->somerel - // } - // ``` - // - // We will add an entrypoint for the arrow itself, keyed to the relation type - // included from the computed userset. - // - // Using the above example, this will add entrypoints for `parent1#somerel` - // and `parent2#somerel`, which are the subjects reached after resolving the - // right side of the arrow. - - // Check if the relation does exist on the allowed type, and only add the entrypoint if present. - relTypeSystem, err := ts.TypeSystemForNamespace(ctx, allowedRelationType.Namespace) - if err != nil { - return err - } + case *core.SetOperation_Child_FunctionedTupleToUserset: + tuplesetRelation := child.FunctionedTupleToUserset.Tupleset.Relation + computedUsersetRelation := child.FunctionedTupleToUserset.ComputedUserset.Relation - if relTypeSystem.HasRelation(computedUsersetRelation) { - err := addSubjectEntrypoint(graph, allowedRelationType.Namespace, computedUsersetRelation, &core.ReachabilityEntrypoint{ - Kind: core.ReachabilityEntrypoint_TUPLESET_TO_USERSET_ENTRYPOINT, - TargetRelation: rr, - ResultStatus: operationResultState, - TuplesetRelation: tuplesetRelation, - }) - if err != nil { - return err - } - } + switch child.FunctionedTupleToUserset.Function { + case core.FunctionedTupleToUserset_FUNCTION_ANY: + // Nothing to change. + + case core.FunctionedTupleToUserset_FUNCTION_ALL: + // Mark as a conditional result. + operationResultState = core.ReachabilityEntrypoint_REACHABLE_CONDITIONAL_RESULT + + default: + return spiceerrors.MustBugf("unknown function type `%T` in reachability graph building", child.FunctionedTupleToUserset.Function) + } + + if err := computeTTUReachability(ctx, graph, tuplesetRelation, computedUsersetRelation, operationResultState, rr, ts); err != nil { + return err } case *core.SetOperation_Child_XNil: @@ -158,7 +128,73 @@ func computeRewriteOpReachability(ctx context.Context, children []*core.SetOpera return nil default: - return fmt.Errorf("unknown set operation child `%T` in reachability graph building", child) + return spiceerrors.MustBugf("unknown set operation child `%T` in reachability graph building", child) + } + } + + return nil +} + +func computeTTUReachability( + ctx context.Context, + graph *core.ReachabilityGraph, + tuplesetRelation string, + computedUsersetRelation string, + operationResultState core.ReachabilityEntrypoint_EntrypointResultStatus, + rr *core.RelationReference, + ts *TypeSystem, +) error { + directRelationTypes, err := ts.AllowedDirectRelationsAndWildcards(tuplesetRelation) + if err != nil { + return err + } + + for _, allowedRelationType := range directRelationTypes { + // For each namespace allowed to be found on the right hand side of the + // tupleset relation, include the *computed userset* relation as an entrypoint. + // + // For example, given a schema: + // + // ``` + // definition user {} + // + // definition parent1 { + // relation somerel: user + // } + // + // definition parent2 { + // relation somerel: user + // } + // + // definition child { + // relation parent: parent1 | parent2 + // permission someperm = parent->somerel + // } + // ``` + // + // We will add an entrypoint for the arrow itself, keyed to the relation type + // included from the computed userset. + // + // Using the above example, this will add entrypoints for `parent1#somerel` + // and `parent2#somerel`, which are the subjects reached after resolving the + // right side of the arrow. + + // Check if the relation does exist on the allowed type, and only add the entrypoint if present. + relTypeSystem, err := ts.TypeSystemForNamespace(ctx, allowedRelationType.Namespace) + if err != nil { + return err + } + + if relTypeSystem.HasRelation(computedUsersetRelation) { + err := addSubjectEntrypoint(graph, allowedRelationType.Namespace, computedUsersetRelation, &core.ReachabilityEntrypoint{ + Kind: core.ReachabilityEntrypoint_TUPLESET_TO_USERSET_ENTRYPOINT, + TargetRelation: rr, + ResultStatus: operationResultState, + TuplesetRelation: tuplesetRelation, + }) + if err != nil { + return err + } } } diff --git a/pkg/typesystem/typesystem.go b/pkg/typesystem/typesystem.go index 2c43831b8a..0a909d7c2d 100644 --- a/pkg/typesystem/typesystem.go +++ b/pkg/typesystem/typesystem.go @@ -407,7 +407,7 @@ func (nts *TypeSystem) Validate(ctx context.Context) (*ValidatedNamespaceTypeSys // Validate the usersets's. usersetRewrite := relation.GetUsersetRewrite() - rerr, err := graph.WalkRewrite(usersetRewrite, func(childOneof *core.SetOperation_Child) interface{} { + rerr, err := graph.WalkRewrite(usersetRewrite, func(childOneof *core.SetOperation_Child) (interface{}, error) { switch child := childOneof.ChildType.(type) { case *core.SetOperation_Child_ComputedUserset: relationName := child.ComputedUserset.GetRelation() @@ -417,17 +417,18 @@ func (nts *TypeSystem) Validate(ctx context.Context) (*ValidatedNamespaceTypeSys NewRelationNotFoundErr(nts.nsDef.Name, relationName), childOneof, relationName, - ) + ), nil } + case *core.SetOperation_Child_TupleToUserset: ttu := child.TupleToUserset if ttu == nil { - return nil + return nil, nil } tupleset := ttu.GetTupleset() if tupleset == nil { - return nil + return nil, nil } relationName := tupleset.GetRelation() @@ -437,19 +438,19 @@ func (nts *TypeSystem) Validate(ctx context.Context) (*ValidatedNamespaceTypeSys NewRelationNotFoundErr(nts.nsDef.Name, relationName), childOneof, relationName, - ) + ), nil } if nspkg.GetRelationKind(found) == iv1.RelationMetadata_PERMISSION { return NewTypeErrorWithSource( NewPermissionUsedOnLeftOfArrowErr(nts.nsDef.Name, relation.Name, relationName), - childOneof, relationName) + childOneof, relationName), nil } // Ensure the tupleset relation doesn't itself import wildcard. referencedWildcard, err := nts.referencesWildcardType(ctx, relationName) if err != nil { - return err + return err, nil } if referencedWildcard != nil { @@ -462,10 +463,56 @@ func (nts *TypeSystem) Validate(ctx context.Context) (*ValidatedNamespaceTypeSys tuple.StringRR(referencedWildcard.ReferencingRelation), ), childOneof, relationName, - ) + ), nil + } + + case *core.SetOperation_Child_FunctionedTupleToUserset: + ttu := child.FunctionedTupleToUserset + if ttu == nil { + return nil, nil + } + + tupleset := ttu.GetTupleset() + if tupleset == nil { + return nil, nil + } + + relationName := tupleset.GetRelation() + found, ok := nts.relationMap[relationName] + if !ok { + return NewTypeErrorWithSource( + NewRelationNotFoundErr(nts.nsDef.Name, relationName), + childOneof, + relationName, + ), nil + } + + if nspkg.GetRelationKind(found) == iv1.RelationMetadata_PERMISSION { + return NewTypeErrorWithSource( + NewPermissionUsedOnLeftOfArrowErr(nts.nsDef.Name, relation.Name, relationName), + childOneof, relationName), nil + } + + // Ensure the tupleset relation doesn't itself import wildcard. + referencedWildcard, err := nts.referencesWildcardType(ctx, relationName) + if err != nil { + return err, nil + } + + if referencedWildcard != nil { + return NewTypeErrorWithSource( + NewWildcardUsedInArrowErr( + nts.nsDef.Name, + relation.Name, + relationName, + referencedWildcard.WildcardType.GetNamespace(), + tuple.StringRR(referencedWildcard.ReferencingRelation), + ), + childOneof, relationName, + ), nil } } - return nil + return nil, nil }) if rerr != nil { return nil, asTypeError(rerr.(error))