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
  • Loading branch information
josephschorr committed May 9, 2024
2 parents 1e847d6 + 654c31e commit 6952b47
Show file tree
Hide file tree
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
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions e2e/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
30 changes: 30 additions & 0 deletions internal/services/v1/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
63 changes: 63 additions & 0 deletions internal/services/v1/experimental.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"io"
"slices"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -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,
Expand Down
235 changes: 235 additions & 0 deletions internal/services/v1/experimental_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
}

0 comments on commit 6952b47

Please sign in to comment.