Skip to content

Commit

Permalink
Merge pull request #1891 from josephschorr/experimental-reflection-ap…
Browse files Browse the repository at this point in the history
…is-part-2

Add ExperimentalDependentRelations reflection API
josephschorr authored May 9, 2024
2 parents 1e847d6 + 654c31e commit 6952b47
Showing 9 changed files with 709 additions and 33 deletions.
2 changes: 1 addition & 1 deletion e2e/go.mod
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ module github.com/authzed/spicedb/e2e
go 1.22.2

require (
github.com/authzed/authzed-go v0.11.2-0.20240506164352-1e5f214fc4f5
github.com/authzed/authzed-go v0.11.2-0.20240507202708-8b150c491e4a
github.com/authzed/grpcutil v0.0.0-20240123092924-129dc0a6a6e1
github.com/authzed/spicedb v1.29.5
github.com/brianvoe/gofakeit/v6 v6.23.2
4 changes: 2 additions & 2 deletions e2e/go.sum
Original file line number Diff line number Diff line change
@@ -27,8 +27,8 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/authzed/authzed-go v0.11.2-0.20240506164352-1e5f214fc4f5 h1:gsc5jhIeaqu/7XKwoACGBWAFEEJqFJK9HRh/uLdEEXw=
github.com/authzed/authzed-go v0.11.2-0.20240506164352-1e5f214fc4f5/go.mod h1:6cIxOivUQPOstQnt0jJ7sRtW91Y0e548zZpy7h8w+mU=
github.com/authzed/authzed-go v0.11.2-0.20240507202708-8b150c491e4a h1:jQFRCVWTfisWRbs2C3Nmn8RoI0/pCSnsdXmHv01EOYg=
github.com/authzed/authzed-go v0.11.2-0.20240507202708-8b150c491e4a/go.mod h1:6cIxOivUQPOstQnt0jJ7sRtW91Y0e548zZpy7h8w+mU=
github.com/authzed/cel-go v0.20.2 h1:GlmLecGry7Z8HU0k+hmaHHUV05ZHrsFxduXHtIePvck=
github.com/authzed/cel-go v0.20.2/go.mod h1:pJHVFWbqUHV1J+klQoZubdKswlbxcsbojda3mye9kiU=
github.com/authzed/grpcutil v0.0.0-20240123092924-129dc0a6a6e1 h1:zBfQzia6Hz45pJBeURTrv1b6HezmejB6UmiGuBilHZM=
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ require (
contrib.go.opencensus.io/exporter/prometheus v0.4.2
github.com/IBM/pgxpoolprometheus v1.1.1
github.com/Masterminds/squirrel v1.5.4
github.com/authzed/authzed-go v0.11.2-0.20240506164352-1e5f214fc4f5
github.com/authzed/authzed-go v0.11.2-0.20240507202708-8b150c491e4a

// NOTE: We are using a *copy* of `cel-go` here to ensure there isn't a conflict
// with the version used in Kubernetes. This is a temporary measure until we can
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -696,6 +696,8 @@ github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5Fc
github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI=
github.com/authzed/authzed-go v0.11.2-0.20240506164352-1e5f214fc4f5 h1:gsc5jhIeaqu/7XKwoACGBWAFEEJqFJK9HRh/uLdEEXw=
github.com/authzed/authzed-go v0.11.2-0.20240506164352-1e5f214fc4f5/go.mod h1:6cIxOivUQPOstQnt0jJ7sRtW91Y0e548zZpy7h8w+mU=
github.com/authzed/authzed-go v0.11.2-0.20240507202708-8b150c491e4a h1:jQFRCVWTfisWRbs2C3Nmn8RoI0/pCSnsdXmHv01EOYg=
github.com/authzed/authzed-go v0.11.2-0.20240507202708-8b150c491e4a/go.mod h1:6cIxOivUQPOstQnt0jJ7sRtW91Y0e548zZpy7h8w+mU=
github.com/authzed/cel-go v0.20.2 h1:GlmLecGry7Z8HU0k+hmaHHUV05ZHrsFxduXHtIePvck=
github.com/authzed/cel-go v0.20.2/go.mod h1:pJHVFWbqUHV1J+klQoZubdKswlbxcsbojda3mye9kiU=
github.com/authzed/consistent v0.1.0 h1:tlh1wvKoRbjRhMm2P+X5WQQyR54SRoS4MyjLOg17Mp8=
30 changes: 30 additions & 0 deletions internal/services/v1/errors.go
Original file line number Diff line number Diff line change
@@ -438,6 +438,36 @@ func (err ErrEmptyPrecondition) GRPCStatus() *status.Status {
)
}

// NewNotAPermissionError constructs a new not a permission error.
func NewNotAPermissionError(relationName string) ErrNotAPermission {
return ErrNotAPermission{
error: fmt.Errorf(
"the relation `%s` is not a permission", relationName,
),
relationName: relationName,
}
}

// ErrNotAPermission indicates that the relation is not a permission.
type ErrNotAPermission struct {
error
relationName string
}

// GRPCStatus implements retrieving the gRPC status for the error.
func (err ErrNotAPermission) GRPCStatus() *status.Status {
return spiceerrors.WithCodeAndDetails(
err,
codes.InvalidArgument,
spiceerrors.ForReason(
v1.ErrorReason_ERROR_REASON_UNKNOWN_RELATION_OR_PERMISSION,
map[string]string{
"relationName": err.relationName,
},
),
)
}

func defaultIfZero[T comparable](value T, defaultValue T) T {
var zero T
if value == zero {
63 changes: 63 additions & 0 deletions internal/services/v1/experimental.go
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import (
"errors"
"io"
"slices"
"sort"
"strings"
"time"

@@ -496,6 +497,68 @@ func (es *experimentalServer) ExperimentalDiffSchema(ctx context.Context, req *v
return resp, nil
}

func (es *experimentalServer) ExperimentalDependentRelations(ctx context.Context, req *v1.ExperimentalDependentRelationsRequest) (*v1.ExperimentalDependentRelationsResponse, error) {
atRevision, revisionReadAt, err := consistency.RevisionFromContext(ctx)
if err != nil {
return nil, shared.RewriteErrorWithoutConfig(ctx, err)
}

ds := datastoremw.MustFromContext(ctx).SnapshotReader(atRevision)
_, vts, err := typesystem.ReadNamespaceAndTypes(ctx, req.DefinitionName, ds)
if err != nil {
return nil, shared.RewriteErrorWithoutConfig(ctx, err)
}

_, ok := vts.GetRelation(req.PermissionName)
if !ok {
return nil, shared.RewriteErrorWithoutConfig(ctx, typesystem.NewRelationNotFoundErr(req.DefinitionName, req.PermissionName))
}

if !vts.IsPermission(req.PermissionName) {
return nil, shared.RewriteErrorWithoutConfig(ctx, NewNotAPermissionError(req.PermissionName))
}

rg := typesystem.ReachabilityGraphFor(vts)
rr, err := rg.RelationsEncounteredForResource(ctx, &core.RelationReference{
Namespace: req.DefinitionName,
Relation: req.PermissionName,
})
if err != nil {
return nil, shared.RewriteErrorWithoutConfig(ctx, err)
}

relations := make([]*v1.ExpRelationReference, 0, len(rr))
for _, r := range rr {
if r.Namespace == req.DefinitionName && r.Relation == req.PermissionName {
continue
}

ts, err := vts.TypeSystemForNamespace(ctx, r.Namespace)
if err != nil {
return nil, shared.RewriteErrorWithoutConfig(ctx, err)
}

relations = append(relations, &v1.ExpRelationReference{
DefinitionName: r.Namespace,
RelationName: r.Relation,
IsPermission: ts.IsPermission(r.Relation),
})
}

sort.Slice(relations, func(i, j int) bool {
if relations[i].DefinitionName == relations[j].DefinitionName {
return relations[i].RelationName < relations[j].RelationName
}

return relations[i].DefinitionName < relations[j].DefinitionName
})

return &v1.ExperimentalDependentRelationsResponse{
Relations: relations,
ReadAt: revisionReadAt,
}, nil
}

func queryForEach(
ctx context.Context,
reader datastore.Reader,
235 changes: 235 additions & 0 deletions internal/services/v1/experimental_test.go
Original file line number Diff line number Diff line change
@@ -1191,3 +1191,238 @@ definition user {}`,
})
}
}

func TestExperimentalDependentRelations(t *testing.T) {
tcs := []struct {
name string
schema string
definitionName string
permissionName string
expectedCode codes.Code
expectedError string
expectedResponse []*v1.ExpRelationReference
}{
{
name: "invalid definition",
schema: `definition user {}`,
definitionName: "invalid",
expectedCode: codes.FailedPrecondition,
expectedError: "object definition `invalid` not found",
},
{
name: "invalid permission",
schema: `definition user {}`,
definitionName: "user",
permissionName: "invalid",
expectedCode: codes.FailedPrecondition,
expectedError: "permission `invalid` not found",
},
{
name: "specified relation",
schema: `
definition user {}
definition document {
relation editor: user
}
`,
definitionName: "document",
permissionName: "editor",
expectedCode: codes.InvalidArgument,
expectedError: "is not a permission",
},
{
name: "simple schema",
schema: `
definition user {}
definition document {
relation unused: user
relation editor: user
relation viewer: user
permission view = viewer + editor
}
`,
definitionName: "document",
permissionName: "view",
expectedResponse: []*v1.ExpRelationReference{
{
DefinitionName: "document",
RelationName: "editor",
IsPermission: false,
},
{
DefinitionName: "document",
RelationName: "viewer",
IsPermission: false,
},
},
},
{
name: "schema with nested relation",
schema: `
definition user {}
definition group {
relation direct_member: user | group#member
relation admin: user
permission member = direct_member + admin
}
definition document {
relation unused: user
relation viewer: user | group#member
permission view = viewer
}
`,
definitionName: "document",
permissionName: "view",
expectedResponse: []*v1.ExpRelationReference{
{
DefinitionName: "document",
RelationName: "viewer",
IsPermission: false,
},
{
DefinitionName: "group",
RelationName: "admin",
IsPermission: false,
},
{
DefinitionName: "group",
RelationName: "direct_member",
IsPermission: false,
},
{
DefinitionName: "group",
RelationName: "member",
IsPermission: true,
},
},
},
{
name: "schema with arrow",
schema: `
definition user {}
definition folder {
relation alsounused: user
relation viewer: user
permission view = viewer
}
definition document {
relation unused: user
relation parent: folder
relation viewer: user
permission view = viewer + parent->view
}
`,
definitionName: "document",
permissionName: "view",
expectedResponse: []*v1.ExpRelationReference{
{
DefinitionName: "document",
RelationName: "parent",
IsPermission: false,
},
{
DefinitionName: "document",
RelationName: "viewer",
IsPermission: false,
},
{
DefinitionName: "folder",
RelationName: "view",
IsPermission: true,
},
{
DefinitionName: "folder",
RelationName: "viewer",
IsPermission: false,
},
},
},
{
name: "empty response",
schema: `
definition user {}
definition folder {
relation alsounused: user
relation viewer: user
permission view = viewer
}
definition document {
relation unused: user
relation parent: folder
relation viewer: user
permission view = viewer + parent->view
permission empty = nil
}
`,
definitionName: "document",
permissionName: "empty",
expectedResponse: []*v1.ExpRelationReference{},
},
{
name: "empty definition",
schema: `
definition user {}
`,
definitionName: "",
permissionName: "empty",
expectedCode: codes.FailedPrecondition,
expectedError: "object definition `` not found",
},
{
name: "empty permission",
schema: `
definition user {}
`,
definitionName: "user",
permissionName: "",
expectedCode: codes.FailedPrecondition,
expectedError: "permission `` not found",
},
}

for _, tc := range tcs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
conn, cleanup, _, _ := testserver.NewTestServer(require.New(t), 0, memdb.DisableGC, true, tf.EmptyDatastore)
expClient := v1.NewExperimentalServiceClient(conn)
schemaClient := v1.NewSchemaServiceClient(conn)
defer cleanup()

// Write the schema.
_, err := schemaClient.WriteSchema(context.Background(), &v1.WriteSchemaRequest{
Schema: tc.schema,
})
require.NoError(t, err)

actual, err := expClient.ExperimentalDependentRelations(context.Background(), &v1.ExperimentalDependentRelationsRequest{
DefinitionName: tc.definitionName,
PermissionName: tc.permissionName,
Consistency: &v1.Consistency{
Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true},
},
})

if tc.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedError)
grpcutil.RequireStatus(t, tc.expectedCode, err)
} else {
require.NoError(t, err)
require.NotNil(t, actual.ReadAt)
actual.ReadAt = nil

testutil.RequireProtoEqual(t, &v1.ExperimentalDependentRelationsResponse{
Relations: tc.expectedResponse,
}, actual, "mismatch in response")
}
})
}
}
72 changes: 43 additions & 29 deletions pkg/typesystem/reachabilitygraph.go
Original file line number Diff line number Diff line change
@@ -121,14 +121,12 @@ func ReachabilityGraphFor(ts *ValidatedNamespaceTypeSystem) *ReachabilityGraph {
return &ReachabilityGraph{ts.TypeSystem, sync.Map{}, sync.Map{}}
}

// RelationsEncountered returns all relations encountered while walking, starting
// at the given subject type and walking to the given resource type.
func (rg *ReachabilityGraph) RelationsEncountered(
// RelationsEncounteredForResource returns all relations that are encountered when walking outward from a resource+relation.
func (rg *ReachabilityGraph) RelationsEncounteredForResource(
ctx context.Context,
subjectType *core.RelationReference,
resourceType *core.RelationReference,
) ([]*core.RelationReference, error) {
_, relationNames, err := rg.entrypointsForSubjectToResource(ctx, subjectType, resourceType, reachabilityFull, entrypointLookupFindAll)
_, relationNames, err := rg.computeEntrypoints(ctx, resourceType, nil /* include all entrypoints */, reachabilityFull, entrypointLookupFindAll)
if err != nil {
return nil, err
}
@@ -151,7 +149,7 @@ func (rg *ReachabilityGraph) AllEntrypointsForSubjectToResource(
subjectType *core.RelationReference,
resourceType *core.RelationReference,
) ([]ReachabilityEntrypoint, error) {
entrypoints, _, err := rg.entrypointsForSubjectToResource(ctx, subjectType, resourceType, reachabilityFull, entrypointLookupFindAll)
entrypoints, _, err := rg.computeEntrypoints(ctx, resourceType, subjectType, reachabilityFull, entrypointLookupFindAll)
return entrypoints, err
}

@@ -165,7 +163,7 @@ func (rg *ReachabilityGraph) OptimizedEntrypointsForSubjectToResource(
subjectType *core.RelationReference,
resourceType *core.RelationReference,
) ([]ReachabilityEntrypoint, error) {
entrypoints, _, err := rg.entrypointsForSubjectToResource(ctx, subjectType, resourceType, reachabilityOptimized, entrypointLookupFindAll)
entrypoints, _, err := rg.computeEntrypoints(ctx, resourceType, subjectType, reachabilityOptimized, entrypointLookupFindAll)
return entrypoints, err
}

@@ -186,7 +184,7 @@ func (rg *ReachabilityGraph) HasOptimizedEntrypointsForSubjectToResource(
}

// TODO(jzelinskie): measure to see if it's worth singleflighting this
found, _, err := rg.entrypointsForSubjectToResource(ctx, subjectType, resourceType, reachabilityOptimized, entrypointLookupFindOne)
found, _, err := rg.computeEntrypoints(ctx, resourceType, subjectType, reachabilityOptimized, entrypointLookupFindOne)
if err != nil {
return false, err
}
@@ -203,10 +201,10 @@ const (
entrypointLookupFindOne
)

func (rg *ReachabilityGraph) entrypointsForSubjectToResource(
func (rg *ReachabilityGraph) computeEntrypoints(
ctx context.Context,
subjectType *core.RelationReference,
resourceType *core.RelationReference,
optionalSubjectType *core.RelationReference,
reachabilityOption reachabilityOption,
entrypointLookupOption entrypointLookupOption,
) ([]ReachabilityEntrypoint, []string, error) {
@@ -216,7 +214,7 @@ func (rg *ReachabilityGraph) entrypointsForSubjectToResource(

collected := &[]ReachabilityEntrypoint{}
encounteredRelations := map[string]struct{}{}
err := rg.collectEntrypoints(ctx, subjectType, resourceType, collected, encounteredRelations, reachabilityOption, entrypointLookupOption)
err := rg.collectEntrypoints(ctx, resourceType, optionalSubjectType, collected, encounteredRelations, reachabilityOption, entrypointLookupOption)
if err != nil {
return nil, maps.Keys(encounteredRelations), err
}
@@ -277,8 +275,8 @@ func (rg *ReachabilityGraph) getOrBuildGraph(ctx context.Context, resourceType *

func (rg *ReachabilityGraph) collectEntrypoints(
ctx context.Context,
subjectType *core.RelationReference,
resourceType *core.RelationReference,
optionalSubjectType *core.RelationReference,
collected *[]ReachabilityEntrypoint,
encounteredRelations map[string]struct{},
reachabilityOption reachabilityOption,
@@ -297,24 +295,35 @@ func (rg *ReachabilityGraph) collectEntrypoints(
return err
}

// Add subject type entrypoints.
subjectTypeEntrypoints, ok := rrg.EntrypointsBySubjectType[subjectType.Namespace]
if ok {
addEntrypoints(subjectTypeEntrypoints, resourceType, collected)
}
if optionalSubjectType != nil {
// Add subject type entrypoints.
subjectTypeEntrypoints, ok := rrg.EntrypointsBySubjectType[optionalSubjectType.Namespace]
if ok {
addEntrypoints(subjectTypeEntrypoints, resourceType, collected, encounteredRelations)
}

if entrypointLookupOption == entrypointLookupFindOne && len(*collected) > 0 {
return nil
}
if entrypointLookupOption == entrypointLookupFindOne && len(*collected) > 0 {
return nil
}

// Add subject relation entrypoints.
subjectRelationEntrypoints, ok := rrg.EntrypointsBySubjectRelation[tuple.JoinRelRef(subjectType.Namespace, subjectType.Relation)]
if ok {
addEntrypoints(subjectRelationEntrypoints, resourceType, collected)
}
// Add subject relation entrypoints.
subjectRelationEntrypoints, ok := rrg.EntrypointsBySubjectRelation[tuple.JoinRelRef(optionalSubjectType.Namespace, optionalSubjectType.Relation)]
if ok {
addEntrypoints(subjectRelationEntrypoints, resourceType, collected, encounteredRelations)
}

if entrypointLookupOption == entrypointLookupFindOne && len(*collected) > 0 {
return nil
if entrypointLookupOption == entrypointLookupFindOne && len(*collected) > 0 {
return nil
}
} else {
// Add all entrypoints.
for _, entrypoints := range rrg.EntrypointsBySubjectType {
addEntrypoints(entrypoints, resourceType, collected, encounteredRelations)
}

for _, entrypoints := range rrg.EntrypointsBySubjectRelation {
addEntrypoints(entrypoints, resourceType, collected, encounteredRelations)
}
}

// Sort the keys to ensure a stable graph is produced.
@@ -325,7 +334,7 @@ func (rg *ReachabilityGraph) collectEntrypoints(
for _, entrypointSetKey := range keys {
entrypointSet := rrg.EntrypointsBySubjectRelation[entrypointSetKey]
if entrypointSet.SubjectRelation != nil && entrypointSet.SubjectRelation.Relation != tuple.Ellipsis {
err := rg.collectEntrypoints(ctx, subjectType, entrypointSet.SubjectRelation, collected, encounteredRelations, reachabilityOption, entrypointLookupOption)
err := rg.collectEntrypoints(ctx, entrypointSet.SubjectRelation, optionalSubjectType, collected, encounteredRelations, reachabilityOption, entrypointLookupOption)
if err != nil {
return err
}
@@ -339,8 +348,13 @@ func (rg *ReachabilityGraph) collectEntrypoints(
return nil
}

func addEntrypoints(entrypoints *core.ReachabilityEntrypoints, parentRelation *core.RelationReference, collected *[]ReachabilityEntrypoint) {
func addEntrypoints(entrypoints *core.ReachabilityEntrypoints, parentRelation *core.RelationReference, collected *[]ReachabilityEntrypoint, encounteredRelations map[string]struct{}) {
for _, entrypoint := range entrypoints.Entrypoints {
if entrypoint.TuplesetRelation != "" {
key := tuple.JoinRelRef(entrypoint.TargetRelation.Namespace, entrypoint.TuplesetRelation)
encounteredRelations[key] = struct{}{}
}

*collected = append(*collected, ReachabilityEntrypoint{entrypoint, parentRelation})
}
}
332 changes: 332 additions & 0 deletions pkg/typesystem/reachabilitygraph_test.go
Original file line number Diff line number Diff line change
@@ -9,13 +9,345 @@ import (

"github.com/authzed/spicedb/internal/datastore/memdb"
datastoremw "github.com/authzed/spicedb/internal/middleware/datastore"
"github.com/authzed/spicedb/pkg/datastore"
ns "github.com/authzed/spicedb/pkg/namespace"
core "github.com/authzed/spicedb/pkg/proto/core/v1"
"github.com/authzed/spicedb/pkg/schemadsl/compiler"
"github.com/authzed/spicedb/pkg/schemadsl/input"
"github.com/authzed/spicedb/pkg/tuple"
)

func TestRelationsEncounteredForResource(t *testing.T) {
tcs := []struct {
name string
schema string
resourceType string
permission string
expectedRelations []string
}{
{
"simple relation",
`definition user {}
definition document {
relation viewer: user
}`,

"document",
"viewer",
[]string{"document#viewer"},
},
{
"simple permission",
`definition user {}
definition document {
relation viewer: user
permission view = viewer + nil
}`,
"document",
"view",
[]string{"document#viewer", "document#view"},
},
{
"permission with multiple relations",
`definition user {}
definition document {
relation viewer: user
relation editor: user
relation owner: user
permission view = viewer + editor + owner
}`,
"document",
"view",
[]string{"document#editor", "document#owner", "document#viewer", "document#view"},
},
{
"permission with multiple relations under intersection",
`definition user {}
definition document {
relation viewer: user
relation editor: user
relation owner: user
permission view = viewer & editor & owner
}`,
"document",
"view",
[]string{"document#viewer", "document#view", "document#editor", "document#owner"},
},
{
"permission with multiple relations under exclusion",
`definition user {}
definition document {
relation viewer: user
relation editor: user
relation owner: user
permission view = viewer - editor - owner
}`,
"document",
"view",
[]string{"document#viewer", "document#view", "document#editor", "document#owner"},
},
{
"permission with arrow",
`definition user {}
definition organization {
relation admin: user
}
definition document {
relation org: organization
relation viewer: user
relation owner: user
permission view = viewer + owner + org->admin
}`,
"document",
"view",
[]string{"document#viewer", "document#owner", "document#org", "document#view", "organization#admin"},
},
{
"permission with subrelation",
`definition user {}
definition group {
relation direct_member: user
relation admin: user
permission member = direct_member + admin
}
definition document {
relation viewer: user | group#member
permission view = viewer
}`,

"document",
"view",

[]string{"document#viewer", "document#view", "group#direct_member", "group#admin", "group#member"},
},
{
"permission with unused relation",
`definition user {}
definition resource {
relation viewer: user
relation editor: user
relation owner: user
relation unused: user
permission view = viewer + editor + owner
}`,

"resource",
"view",

[]string{"resource#viewer", "resource#editor", "resource#owner", "resource#view"},
},
{
"permission with multiple arrows",
`definition user {}
definition organization {
relation admin: user
relation banned: user
relation member: user
permission can_admin = admin - banned
}
definition group {
relation admin: user
relation direct_member: user | group#member
relation org: organization
permission member = direct_member + admin + org->can_admin
}
definition document {
relation viewer: user | group#member
relation owner: user
relation org: organization
permission view = viewer + owner + org->member
}`,

"document",
"view",

[]string{
"document#viewer", "document#owner", "document#org", "document#view",
"group#admin", "group#direct_member", "group#member", "organization#admin",
"organization#member", "organization#can_admin", "group#org", "organization#banned",
},
},
{
"permission with multiple arrows but only one used",
`definition user {}
definition organization {
relation admin: user
relation banned: user
relation member: user
permission can_admin = admin - banned
}
definition group {
relation admin: user
relation direct_member: user | group#member
relation org: organization
permission member = direct_member + admin + org->can_admin
}
definition document {
relation viewer: user | group#member
relation owner: user
relation org: organization
permission view = viewer + owner
}`,

"document",
"view",

[]string{
"document#viewer", "document#owner", "document#view",
"group#admin", "group#direct_member", "group#member", "organization#admin",
"organization#can_admin", "group#org", "organization#banned",
},
},
{
"permission with multiple items but only one path used",
`definition user {}
definition organization {
relation admin: user
relation banned: user
relation member: user
permission can_admin = admin - banned
}
definition group {
relation admin: user
relation direct_member: user | group#member
relation org: organization
permission member = direct_member + admin + org->can_admin
}
definition document {
relation viewer: user
relation owner: user
relation org: organization
permission view = viewer + owner
}`,

"document",
"view",

[]string{
"document#viewer", "document#owner", "document#view",
},
},
{
"permission with many indirect relations",
`definition user {}
definition first {
relation member: second#member
}
definition second {
relation member: third#member
}
definition third {
relation member: user
}
definition document {
relation viewer: user | first#member
permission view = viewer
}`,

"document",
"view",

[]string{
"document#viewer", "document#view", "first#member", "second#member", "third#member",
},
},
}

for _, tc := range tcs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)

ds, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC)
require.NoError(err)

ctx := datastoremw.ContextWithDatastore(context.Background(), ds)

compiled, err := compiler.Compile(compiler.InputSchema{
Source: input.Source("schema"),
SchemaString: tc.schema,
}, compiler.AllowUnprefixedObjectType())
require.NoError(err)

// Write the schema.
_, err = ds.ReadWriteTx(context.Background(), func(ctx context.Context, tx datastore.ReadWriteTransaction) error {
for _, nsDef := range compiled.ObjectDefinitions {
if err := tx.WriteNamespaces(ctx, nsDef); err != nil {
return err
}
}

return nil
})
require.NoError(err)

lastRevision, err := ds.HeadRevision(context.Background())
require.NoError(err)

reader := ds.SnapshotReader(lastRevision)

_, vts, err := ReadNamespaceAndTypes(ctx, tc.resourceType, reader)
require.NoError(err)

rg := ReachabilityGraphFor(vts)

relations, err := rg.RelationsEncounteredForResource(ctx, &core.RelationReference{
Namespace: tc.resourceType,
Relation: tc.permission,
})
require.NoError(err)

relationStrs := make([]string, 0, len(relations))
for _, relation := range relations {
relationStrs = append(relationStrs, tuple.StringRR(relation))
}

sort.Strings(relationStrs)
sort.Strings(tc.expectedRelations)

require.Equal(tc.expectedRelations, relationStrs)
})
}
}

func TestReachabilityGraph(t *testing.T) {
testCases := []struct {
name string

0 comments on commit 6952b47

Please sign in to comment.