Skip to content

Commit

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

Add ExperimentalComputablePermissions API
  • Loading branch information
josephschorr committed May 10, 2024
2 parents 6952b47 + c1f0fb2 commit 8339620
Show file tree
Hide file tree
Showing 4 changed files with 559 additions and 0 deletions.
75 changes: 75 additions & 0 deletions internal/services/v1/experimental.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,81 @@ func (es *experimentalServer) ExperimentalDiffSchema(ctx context.Context, req *v
return resp, nil
}

func (es *experimentalServer) ExperimentalComputablePermissions(ctx context.Context, req *v1.ExperimentalComputablePermissionsRequest) (*v1.ExperimentalComputablePermissionsResponse, 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)
}

relationName := req.RelationName
if relationName == "" {
relationName = tuple.Ellipsis
} else {
if _, ok := vts.GetRelation(relationName); !ok {
return nil, shared.RewriteErrorWithoutConfig(ctx, typesystem.NewRelationNotFoundErr(req.DefinitionName, relationName))
}
}

allNamespaces, err := ds.ListAllNamespaces(ctx)
if err != nil {
return nil, shared.RewriteErrorWithoutConfig(ctx, err)
}

allDefinitions := make([]*core.NamespaceDefinition, 0, len(allNamespaces))
for _, ns := range allNamespaces {
allDefinitions = append(allDefinitions, ns.Definition)
}

rg := typesystem.ReachabilityGraphFor(vts)
rr, err := rg.RelationsEncounteredForSubject(ctx, allDefinitions, &core.RelationReference{
Namespace: req.DefinitionName,
Relation: relationName,
})
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.RelationName {
continue
}

if req.OptionalDefinitionNameFilter != "" && !strings.HasPrefix(r.Namespace, req.OptionalDefinitionNameFilter) {
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.ExperimentalComputablePermissionsResponse{
Permissions: relations,
ReadAt: revisionReadAt,
}, nil
}

func (es *experimentalServer) ExperimentalDependentRelations(ctx context.Context, req *v1.ExperimentalDependentRelationsRequest) (*v1.ExperimentalDependentRelationsResponse, error) {
atRevision, revisionReadAt, err := consistency.RevisionFromContext(ctx)
if err != nil {
Expand Down
201 changes: 201 additions & 0 deletions internal/services/v1/experimental_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1426,3 +1426,204 @@ func TestExperimentalDependentRelations(t *testing.T) {
})
}
}

func TestExperimentalComputablePermissions(t *testing.T) {
tcs := []struct {
name string
schema string
definitionName string
relationName string
filter 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 relation",
schema: `definition user {}`,
definitionName: "user",
relationName: "invalid",
expectedCode: codes.FailedPrecondition,
expectedError: "relation/permission `invalid` not found",
},
{
name: "basic",
schema: `
definition user {}
definition document {
relation unused: user
relation editor: user
relation viewer: user
permission view = viewer + editor
permission another = unused
}`,
definitionName: "user",
relationName: "",
expectedResponse: []*v1.ExpRelationReference{
{
DefinitionName: "document",
RelationName: "another",
IsPermission: true,
},
{
DefinitionName: "document",
RelationName: "editor",
IsPermission: false,
},
{
DefinitionName: "document",
RelationName: "unused",
IsPermission: false,
},
{
DefinitionName: "document",
RelationName: "view",
IsPermission: true,
},
{
DefinitionName: "document",
RelationName: "viewer",
IsPermission: false,
},
},
},
{
name: "filtered",
schema: `
definition user {}
definition folder {
relation viewer: user
}
definition document {
relation unused: user
relation editor: user
relation viewer: user
permission view = viewer + editor
permission another = unused
}`,
definitionName: "user",
relationName: "",
filter: "folder",
expectedResponse: []*v1.ExpRelationReference{
{
DefinitionName: "folder",
RelationName: "viewer",
IsPermission: false,
},
},
},
{
name: "basic relation",
schema: `
definition user {}
definition document {
relation unused: user
relation editor: user
relation viewer: user
permission view = viewer + editor
permission another = unused
}`,
definitionName: "document",
relationName: "viewer",
expectedResponse: []*v1.ExpRelationReference{
{
DefinitionName: "document",
RelationName: "view",
IsPermission: true,
},
},
},
{
name: "multiple permissions",
schema: `
definition user {}
definition document {
relation unused: user
relation editor: user
relation viewer: user
permission view = viewer + editor
permission only_view = viewer
permission another = unused
}`,
definitionName: "document",
relationName: "viewer",
expectedResponse: []*v1.ExpRelationReference{
{
DefinitionName: "document",
RelationName: "only_view",
IsPermission: true,
},
{
DefinitionName: "document",
RelationName: "view",
IsPermission: true,
},
},
},
{
name: "empty response",
schema: `
definition user {}
definition document {
relation unused: user
permission empty = nil
}
`,
definitionName: "document",
relationName: "unused",
expectedResponse: []*v1.ExpRelationReference{},
},
}

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.ExperimentalComputablePermissions(context.Background(), &v1.ExperimentalComputablePermissionsRequest{
DefinitionName: tc.definitionName,
RelationName: tc.relationName,
OptionalDefinitionNameFilter: tc.filter,
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.ExperimentalComputablePermissionsResponse{
Permissions: tc.expectedResponse,
}, actual, "mismatch in response")
}
})
}
}
74 changes: 74 additions & 0 deletions pkg/typesystem/reachabilitygraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"github.com/cespare/xxhash/v2"
"golang.org/x/exp/maps"

"github.com/authzed/spicedb/pkg/genutil/mapz"
core "github.com/authzed/spicedb/pkg/proto/core/v1"
"github.com/authzed/spicedb/pkg/spiceerrors"
"github.com/authzed/spicedb/pkg/tuple"
)

Expand Down Expand Up @@ -142,6 +144,78 @@ func (rg *ReachabilityGraph) RelationsEncounteredForResource(
return relationRefs, nil
}

// RelationsEncounteredForSubject returns all relations that are encountered when walking outward from a subject+relation.
func (rg *ReachabilityGraph) RelationsEncounteredForSubject(
ctx context.Context,
allDefinitions []*core.NamespaceDefinition,
startingSubjectType *core.RelationReference,
) ([]*core.RelationReference, error) {
if startingSubjectType.Namespace != rg.ts.nsDef.Name {
return nil, spiceerrors.MustBugf("gave mismatching namespace name for subject type to reachability graph")
}

allRelationNames := mapz.NewSet[string]()

subjectTypesToCheck := []*core.RelationReference{startingSubjectType}

// TODO(jschorr): optimize this to not require walking over all types recursively.
added := mapz.NewSet[string]()
for {
if len(subjectTypesToCheck) == 0 {
break
}

collected := &[]ReachabilityEntrypoint{}
for _, nsDef := range allDefinitions {
nts, err := rg.ts.TypeSystemForNamespace(ctx, nsDef.Name)
if err != nil {
return nil, err
}

nrg := ReachabilityGraphFor(&ValidatedNamespaceTypeSystem{nts})

for _, relation := range nsDef.Relation {
for _, subjectType := range subjectTypesToCheck {
if subjectType.Namespace == nsDef.Name && subjectType.Relation == relation.Name {
continue
}

encounteredRelations := map[string]struct{}{}
err := nrg.collectEntrypoints(ctx, &core.RelationReference{
Namespace: nsDef.Name,
Relation: relation.Name,
}, subjectType, collected, encounteredRelations, reachabilityFull, entrypointLookupFindAll)
if err != nil {
return nil, err
}
}
}
}

subjectTypesToCheck = make([]*core.RelationReference, 0, len(*collected))

for _, entrypoint := range *collected {
st := tuple.JoinRelRef(entrypoint.re.TargetRelation.Namespace, entrypoint.re.TargetRelation.Relation)
if !added.Add(st) {
continue
}

allRelationNames.Add(st)
subjectTypesToCheck = append(subjectTypesToCheck, entrypoint.re.TargetRelation)
}
}

relationRefs := make([]*core.RelationReference, 0, allRelationNames.Len())
for _, relationName := range allRelationNames.AsSlice() {
namespace, relation := tuple.MustSplitRelRef(relationName)
relationRefs = append(relationRefs, &core.RelationReference{
Namespace: namespace,
Relation: relation,
})
}
return relationRefs, nil
}

// AllEntrypointsForSubjectToResource returns the entrypoints into the reachability graph, starting
// at the given subject type and walking to the given resource type.
func (rg *ReachabilityGraph) AllEntrypointsForSubjectToResource(
Expand Down

0 comments on commit 8339620

Please sign in to comment.