Zanzana: app platform style write APIs (#112812)
* refactor zanzana client instantiation * refactor client imports * POC write API (Mutate) * fix linter * delete exisitng folder parents * refactor common functions * minor refactor * groupd operations by type * atomic folder operations * use deleteExisting for deletes * Add tests for folders * more tests * resource permissions tests * add more tests * fix mock zanzana client * fix linter * fix linter * re-use types from apps * add some comments to the protobuf
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
)
|
||||
|
||||
type OperationGroup string
|
||||
|
||||
const (
|
||||
OperationGroupFolder OperationGroup = "folder"
|
||||
OperationGroupPermission OperationGroup = "permission"
|
||||
)
|
||||
|
||||
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "server.Mutate")
|
||||
defer span.End()
|
||||
|
||||
defer func(t time.Time) {
|
||||
s.metrics.requestDurationSeconds.WithLabelValues("server.Mutate", req.GetNamespace()).Observe(time.Since(t).Seconds())
|
||||
}(time.Now())
|
||||
|
||||
res, err := s.mutate(ctx, req)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
|
||||
return nil, errors.New("failed to perform mutate request")
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
||||
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storeInf, err := s.getStoreInfo(ctx, req.Namespace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get openfga store: %w", err)
|
||||
}
|
||||
|
||||
groupedOperations, err := groupByOperation(req.GetOperations())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to group operations: %w", err)
|
||||
}
|
||||
|
||||
for operationGroup, operations := range groupedOperations {
|
||||
switch operationGroup {
|
||||
case OperationGroupFolder:
|
||||
if err := s.mutateFolders(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate folder: %w", err)
|
||||
}
|
||||
case OperationGroupPermission:
|
||||
if err := s.mutateResourcePermissions(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate resource permissions: %w", err)
|
||||
}
|
||||
default:
|
||||
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
|
||||
}
|
||||
}
|
||||
|
||||
return &authzextv1.MutateResponse{}, nil
|
||||
}
|
||||
|
||||
func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, error) {
|
||||
switch operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_SetFolderParent, *authzextv1.MutateOperation_DeleteFolder:
|
||||
return OperationGroupFolder, nil
|
||||
case *authzextv1.MutateOperation_CreatePermission, *authzextv1.MutateOperation_DeletePermission:
|
||||
return OperationGroupPermission, nil
|
||||
}
|
||||
return OperationGroup(""), errors.New("unsupported mutate operation type")
|
||||
}
|
||||
|
||||
func groupByOperation(operations []*authzextv1.MutateOperation) (map[OperationGroup][]*authzextv1.MutateOperation, error) {
|
||||
grouped := make(map[OperationGroup][]*authzextv1.MutateOperation)
|
||||
for _, operation := range operations {
|
||||
operationGroup, err := getOperationGroup(operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped[operationGroup] = append(grouped[operationGroup], operation)
|
||||
}
|
||||
|
||||
return grouped, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func (s *Server) mutateFolders(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
|
||||
ctx, span := s.tracer.Start(ctx, "server.mutateFolder")
|
||||
defer span.End()
|
||||
|
||||
writeTuples := make([]*openfgav1.TupleKey, 0)
|
||||
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
for _, operation := range operations {
|
||||
switch op := operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_SetFolderParent:
|
||||
tuple, err := s.getFolderWriteTuple(ctx, store, op.SetFolderParent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tuple != nil {
|
||||
writeTuples = append(writeTuples, tuple)
|
||||
}
|
||||
|
||||
// Delete existing parent tuples
|
||||
if op.SetFolderParent.GetDeleteExisting() {
|
||||
tuples, err := s.getFolderDeleteTuples(ctx, store, op.SetFolderParent.GetFolder(), op.SetFolderParent.GetParent(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteTuples = append(deleteTuples, tuples...)
|
||||
}
|
||||
case *authzextv1.MutateOperation_DeleteFolder:
|
||||
tuples, err := s.getFolderDeleteTuples(ctx, store, op.DeleteFolder.GetFolder(), op.DeleteFolder.GetParent(), op.DeleteFolder.GetDeleteExisting())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteTuples = append(deleteTuples, tuples...)
|
||||
default:
|
||||
s.logger.Debug("unsupported mutate operation", "operation", op)
|
||||
}
|
||||
}
|
||||
|
||||
if len(writeTuples) == 0 && len(deleteTuples) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: writeTuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}
|
||||
if len(deleteTuples) > 0 {
|
||||
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.openfga.Write(ctx, writeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write folder tuples", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getFolderWriteTuple(ctx context.Context, store *storeInfo, req *authzextv1.SetFolderParentOperation) (*openfgav1.TupleKey, error) {
|
||||
// Folder is at the root level
|
||||
if req.GetParent() == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.ContainsAny(req.GetFolder(), "#:") {
|
||||
return nil, fmt.Errorf("folder UID contains invalid characters: %s", req.GetFolder())
|
||||
}
|
||||
|
||||
tuple := zanzana.NewFolderParentTuple(req.GetFolder(), req.GetParent())
|
||||
return tuple, nil
|
||||
}
|
||||
|
||||
func (s *Server) getFolderDeleteTuples(ctx context.Context, store *storeInfo, folderUID string, parentUID string, deleteExisting bool) ([]*openfgav1.TupleKeyWithoutCondition, error) {
|
||||
tupleKeysToDelete := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
if folderUID != "" && parentUID != "" && !deleteExisting {
|
||||
tuple := zanzana.NewFolderParentTuple(folderUID, parentUID)
|
||||
tupleKeysToDelete = append(tupleKeysToDelete, &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuple.GetUser(),
|
||||
Relation: tuple.GetRelation(),
|
||||
Object: tuple.GetObject(),
|
||||
})
|
||||
}
|
||||
|
||||
if deleteExisting {
|
||||
parentTuples, err := s.listFolderParents(ctx, store, folderUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list folder parents: %w", err)
|
||||
}
|
||||
|
||||
for _, tuple := range parentTuples {
|
||||
tupleKeysToDelete = append(tupleKeysToDelete, &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuple.Key.User,
|
||||
Relation: tuple.Key.Relation,
|
||||
Object: tuple.Key.Object,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tupleKeysToDelete, nil
|
||||
}
|
||||
|
||||
func (s *Server) listFolderParents(ctx context.Context, store *storeInfo, folderUID string) ([]*openfgav1.Tuple, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "server.listFolderParents")
|
||||
defer span.End()
|
||||
|
||||
object := zanzana.NewFolderIdent(folderUID)
|
||||
resp, err := s.openfga.Read(ctx, &openfgav1.ReadRequest{
|
||||
StoreId: store.ID,
|
||||
TupleKey: &openfgav1.ReadRequestTupleKey{
|
||||
Object: object,
|
||||
Relation: zanzana.RelationParent,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list folder parents: %w", err)
|
||||
}
|
||||
|
||||
return resp.Tuples, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutateFolders(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewFolderParentTuple("11", "1"),
|
||||
common.NewFolderParentTuple("12", "1"),
|
||||
common.NewFolderParentTuple("111", "11"),
|
||||
common.NewFolderParentTuple("112", "11"),
|
||||
common.NewFolderParentTuple("broken", "foo"),
|
||||
common.NewFolderParentTuple("broken", "bar"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutateFolders(t *testing.T, srv *Server) {
|
||||
setupMutateFolders(t, srv)
|
||||
|
||||
t.Run("should create new folder parent relation", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_SetFolderParent{
|
||||
SetFolderParent: &v1.SetFolderParentOperation{
|
||||
Folder: "new-folder",
|
||||
Parent: "1",
|
||||
DeleteExisting: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Object: "folder:new-folder",
|
||||
Relation: "parent",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "folder:new-folder", res.Tuples[0].Key.Object)
|
||||
require.Equal(t, "parent", res.Tuples[0].Key.Relation)
|
||||
require.Equal(t, "folder:1", res.Tuples[0].Key.User)
|
||||
})
|
||||
|
||||
t.Run("should delete folder parent relation", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteFolder{
|
||||
DeleteFolder: &v1.DeleteFolderOperation{
|
||||
Folder: "11",
|
||||
Parent: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Object: "folder:11",
|
||||
Relation: "parent",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
|
||||
t.Run("should clean up all parent relations", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteFolder{
|
||||
DeleteFolder: &v1.DeleteFolderOperation{
|
||||
Folder: "broken",
|
||||
DeleteExisting: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Object: "folder:broken",
|
||||
Relation: "parent",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
|
||||
t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_SetFolderParent{
|
||||
SetFolderParent: &v1.SetFolderParentOperation{
|
||||
Folder: "new-folder-2",
|
||||
Parent: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteFolder{
|
||||
DeleteFolder: &v1.DeleteFolderOperation{
|
||||
Folder: "12",
|
||||
Parent: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Object: "folder:new-folder-2",
|
||||
Relation: "parent",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "folder:new-folder-2", res.Tuples[0].Key.Object)
|
||||
require.Equal(t, "parent", res.Tuples[0].Key.Relation)
|
||||
require.Equal(t, "folder:1", res.Tuples[0].Key.User)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Object: "folder:12",
|
||||
Relation: "parent",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
var (
|
||||
errEmptyName = errors.New("name cannot be empty")
|
||||
errInvalidBasicRole = errors.New("invalid basic role")
|
||||
errUnknownKind = errors.New("unknown permission kind")
|
||||
)
|
||||
|
||||
func (s *Server) mutateResourcePermissions(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
|
||||
ctx, span := s.tracer.Start(ctx, "server.mutateResourcePermissions")
|
||||
defer span.End()
|
||||
|
||||
writeTuples := make([]*openfgav1.TupleKey, 0)
|
||||
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
for _, operation := range operations {
|
||||
switch op := operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_CreatePermission:
|
||||
tuple, err := s.getPermissionWriteTuple(ctx, op.CreatePermission)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuples = append(writeTuples, tuple)
|
||||
case *authzextv1.MutateOperation_DeletePermission:
|
||||
tuple, err := s.getPermissionDeleteTuple(ctx, op.DeletePermission)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteTuples = append(deleteTuples, tuple)
|
||||
default:
|
||||
s.logger.Debug("unsupported mutate operation", "operation", op)
|
||||
}
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: writeTuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}
|
||||
if len(deleteTuples) > 0 {
|
||||
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.openfga.Write(ctx, writeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write resource permission tuples", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getPermissionWriteTuple(ctx context.Context, req *authzextv1.CreatePermissionOperation) (*openfgav1.TupleKey, error) {
|
||||
resource := req.GetResource()
|
||||
permission := req.GetPermission()
|
||||
object := zanzana.NewObjectEntry(toZanzanaType(resource.GetGroup()), resource.GetGroup(), resource.GetResource(), "", resource.GetName())
|
||||
tuple, err := NewResourceTuple(object, resource, permission)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tuple, nil
|
||||
}
|
||||
|
||||
func (s *Server) getPermissionDeleteTuple(ctx context.Context, req *authzextv1.DeletePermissionOperation) (*openfgav1.TupleKeyWithoutCondition, error) {
|
||||
resource := req.GetResource()
|
||||
permission := req.GetPermission()
|
||||
object := zanzana.NewObjectEntry(toZanzanaType(resource.GetGroup()), resource.GetGroup(), resource.GetResource(), "", resource.GetName())
|
||||
tuple, err := NewResourceTuple(object, resource, permission)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &openfgav1.TupleKeyWithoutCondition{
|
||||
User: tuple.GetUser(),
|
||||
Relation: tuple.GetRelation(),
|
||||
Object: tuple.GetObject(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toZanzanaType(apiGroup string) string {
|
||||
if apiGroup == "folder.grafana.app" {
|
||||
return zanzana.TypeFolder
|
||||
}
|
||||
return zanzana.TypeResource
|
||||
}
|
||||
|
||||
func NewResourceTuple(object string, resource *authzextv1.Resource, perm *authzextv1.Permission) (*openfgav1.TupleKey, error) {
|
||||
// Typ is "folder" or "resource"
|
||||
typ := toZanzanaType(resource.Group)
|
||||
|
||||
// subject
|
||||
subject, err := toZanzanaSubject(perm.GetKind(), perm.GetName())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := &openfgav1.TupleKey{
|
||||
// e.g. "user:{uid}", "serviceaccount:{uid}", "team:{uid}", "basicrole:{viewer|editor|admin}"
|
||||
User: subject,
|
||||
// "view", "edit", "admin"
|
||||
Relation: strings.ToLower(perm.Verb),
|
||||
// e.g. "folder:{name}" or "resource:{apiGroup}/{resource}/{name}"
|
||||
Object: object,
|
||||
}
|
||||
|
||||
// For resources we add a condition to filter by apiGroup/resource
|
||||
// e.g "group_filter": {"group_resource": "dashboards.grafana.app/dashboards"}
|
||||
if typ == zanzana.TypeResource {
|
||||
key.Condition = &openfgav1.RelationshipCondition{
|
||||
Name: "group_filter",
|
||||
Context: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"group_resource": structpb.NewStringValue(
|
||||
resource.GetGroup() + "/" + resource.GetResource(),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func toZanzanaSubject(kind string, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", errEmptyName
|
||||
}
|
||||
iamKind := iamv0.ResourcePermissionSpecPermissionKind(kind)
|
||||
switch iamKind {
|
||||
case iamv0.ResourcePermissionSpecPermissionKindUser:
|
||||
return zanzana.NewTupleEntry(zanzana.TypeUser, name, ""), nil
|
||||
case iamv0.ResourcePermissionSpecPermissionKindServiceAccount:
|
||||
return zanzana.NewTupleEntry(zanzana.TypeServiceAccount, name, ""), nil
|
||||
case iamv0.ResourcePermissionSpecPermissionKindTeam:
|
||||
return zanzana.NewTupleEntry(zanzana.TypeTeam, name, ""), nil
|
||||
case iamv0.ResourcePermissionSpecPermissionKindBasicRole:
|
||||
basicRole := zanzana.TranslateBasicRole(name)
|
||||
if basicRole == "" {
|
||||
return "", fmt.Errorf("%w: %s", errInvalidBasicRole, name)
|
||||
}
|
||||
|
||||
// e.g role:basic_viewer#assignee
|
||||
return zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, zanzana.RelationAssignee), nil
|
||||
}
|
||||
|
||||
// should not happen since we are after create
|
||||
// validation webhook should have caught invalid kinds
|
||||
return "", errUnknownKind
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutateResourcePermissions(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewTypedResourceTuple("user:2", common.RelationGet, common.TypeFolder, folderGroup, folderResource, "", "1"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutateResourcePermissions(t *testing.T, srv *Server) {
|
||||
setupMutateResourcePermissions(t, srv)
|
||||
|
||||
t.Run("should create new resource permission", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "foo",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "bar",
|
||||
Verb: common.RelationGet,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationGet,
|
||||
Object: "resource:dashboard.grafana.app/dashboards/foo",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "user:bar", res.Tuples[0].Key.User)
|
||||
require.Equal(t, common.RelationGet, res.Tuples[0].Key.Relation)
|
||||
require.Equal(t, "resource:dashboard.grafana.app/dashboards/foo", res.Tuples[0].Key.Object)
|
||||
})
|
||||
|
||||
t.Run("should delete resource permission", func(t *testing.T) {
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
User: "user:1",
|
||||
Object: "resource:dashboard.grafana.app/dashboards/1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 2)
|
||||
|
||||
_, err = srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "1",
|
||||
Verb: common.RelationUpdate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationGet,
|
||||
Object: "resource:dashboard.grafana.app/dashboards/1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "user:1", res.Tuples[0].Key.User)
|
||||
require.Equal(t, common.RelationGet, res.Tuples[0].Key.Relation)
|
||||
require.Equal(t, "resource:dashboard.grafana.app/dashboards/1", res.Tuples[0].Key.Object)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutate(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewFolderParentTuple("11", "1"),
|
||||
common.NewFolderParentTuple("12", "1"),
|
||||
common.NewFolderParentTuple("111", "11"),
|
||||
common.NewFolderParentTuple("112", "11"),
|
||||
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewTypedResourceTuple("user:2", common.RelationGet, common.TypeFolder, folderGroup, folderResource, "", "1"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutate(t *testing.T, srv *Server) {
|
||||
setupMutate(t, srv)
|
||||
|
||||
t.Run("should perform multiple mutate operations", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_SetFolderParent{
|
||||
SetFolderParent: &v1.SetFolderParentOperation{
|
||||
Folder: "new-folder",
|
||||
Parent: "1",
|
||||
DeleteExisting: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_CreatePermission{
|
||||
CreatePermission: &v1.CreatePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "foo",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "bar",
|
||||
Verb: common.RelationGet,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "1",
|
||||
Verb: common.RelationUpdate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeletePermission{
|
||||
DeletePermission: &v1.DeletePermissionOperation{
|
||||
Resource: &v1.Resource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Name: "1",
|
||||
},
|
||||
Permission: &v1.Permission{
|
||||
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
|
||||
Name: "1",
|
||||
Verb: common.RelationGet,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Object: "folder:new-folder",
|
||||
Relation: "parent",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "folder:new-folder", res.Tuples[0].Key.Object)
|
||||
require.Equal(t, "parent", res.Tuples[0].Key.Relation)
|
||||
require.Equal(t, "folder:1", res.Tuples[0].Key.User)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationGet,
|
||||
Object: "resource:dashboard.grafana.app/dashboards/foo",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "user:bar", res.Tuples[0].Key.User)
|
||||
require.Equal(t, common.RelationGet, res.Tuples[0].Key.Relation)
|
||||
require.Equal(t, "resource:dashboard.grafana.app/dashboards/foo", res.Tuples[0].Key.Object)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationGet,
|
||||
Object: "resource:dashboard.grafana.app/dashboards/1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
}
|
||||
@@ -44,6 +44,36 @@ const (
|
||||
statusSubresource = "status"
|
||||
)
|
||||
|
||||
func setup(t *testing.T, srv *Server) *Server {
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewGroupResourceTuple("user:2", common.RelationGet, dashboardGroup, dashboardResource, ""),
|
||||
common.NewGroupResourceTuple("user:2", common.RelationUpdate, dashboardGroup, dashboardResource, ""),
|
||||
common.NewResourceTuple("user:3", common.RelationSetView, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "3"),
|
||||
common.NewFolderResourceTuple("user:5", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewFolderTuple("user:6", common.RelationGet, "1"),
|
||||
common.NewGroupResourceTuple("user:7", common.RelationGet, folderGroup, folderResource, ""),
|
||||
common.NewFolderParentTuple("5", "4"),
|
||||
common.NewFolderParentTuple("6", "5"),
|
||||
common.NewFolderResourceTuple("user:8", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "5"),
|
||||
common.NewFolderResourceTuple("user:9", common.RelationCreate, dashboardGroup, dashboardResource, "", "5"),
|
||||
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "10"),
|
||||
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "11"),
|
||||
common.NewGroupResourceTuple("user:11", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource),
|
||||
common.NewFolderResourceTuple("user:12", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "5"),
|
||||
common.NewFolderResourceTuple("user:13", common.RelationGet, folderGroup, folderResource, statusSubresource, "5"),
|
||||
common.NewTypedResourceTuple("user:14", common.RelationGet, common.TypeTeam, teamGroup, teamResource, statusSubresource, "1"),
|
||||
common.NewTypedResourceTuple("user:15", common.RelationGet, common.TypeUser, userGroup, userResource, statusSubresource, "1"),
|
||||
common.NewTypedResourceTuple("user:16", common.RelationGet, common.TypeServiceAccount, serviceAccountGroup, serviceAccountResource, statusSubresource, "1"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
@@ -64,27 +94,43 @@ func TestIntegrationServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
srv := setup(t, testStore, cfg)
|
||||
srv := setupOpenFGAServer(t, testStore, cfg)
|
||||
t.Run("test check", func(t *testing.T) {
|
||||
setup(t, srv)
|
||||
testCheck(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test list", func(t *testing.T) {
|
||||
setup(t, srv)
|
||||
testList(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test list streaming", func(t *testing.T) {
|
||||
setup(t, srv)
|
||||
srv.cfg.UseStreamedListObjects = true
|
||||
testList(t, srv)
|
||||
srv.cfg.UseStreamedListObjects = false
|
||||
})
|
||||
|
||||
t.Run("test batch check", func(t *testing.T) {
|
||||
setup(t, srv)
|
||||
testBatchCheck(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate", func(t *testing.T) {
|
||||
testMutate(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate folders", func(t *testing.T) {
|
||||
testMutateFolders(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate resource permissions", func(t *testing.T) {
|
||||
testMutateResourcePermissions(t, srv)
|
||||
})
|
||||
}
|
||||
|
||||
func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
t.Helper()
|
||||
|
||||
store, err := store.NewEmbeddedStore(cfg, testDB, log.NewNopLogger())
|
||||
@@ -95,38 +141,25 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService(), prometheus.NewRegistry())
|
||||
require.NoError(t, err)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func setupOpenFGADatabase(t *testing.T, srv *Server, tuples []*openfgav1.TupleKey) *Server {
|
||||
t.Helper()
|
||||
|
||||
storeInf, err := srv.getStoreInfo(context.Background(), namespace)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clean up any existing store
|
||||
_, err = srv.openfga.DeleteStore(context.Background(), &openfgav1.DeleteStoreRequest{
|
||||
StoreId: storeInf.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// seed tuples
|
||||
writes := &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: []*openfgav1.TupleKey{
|
||||
common.NewResourceTuple("user:1", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewResourceTuple("user:1", common.RelationUpdate, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewGroupResourceTuple("user:2", common.RelationGet, dashboardGroup, dashboardResource, ""),
|
||||
common.NewGroupResourceTuple("user:2", common.RelationUpdate, dashboardGroup, dashboardResource, ""),
|
||||
common.NewResourceTuple("user:3", common.RelationSetView, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewFolderResourceTuple("user:4", common.RelationGet, dashboardGroup, dashboardResource, "", "3"),
|
||||
common.NewFolderResourceTuple("user:5", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "1"),
|
||||
common.NewFolderTuple("user:6", common.RelationGet, "1"),
|
||||
common.NewGroupResourceTuple("user:7", common.RelationGet, folderGroup, folderResource, ""),
|
||||
common.NewFolderParentTuple("5", "4"),
|
||||
common.NewFolderParentTuple("6", "5"),
|
||||
common.NewFolderResourceTuple("user:8", common.RelationSetEdit, dashboardGroup, dashboardResource, "", "5"),
|
||||
common.NewFolderResourceTuple("user:9", common.RelationCreate, dashboardGroup, dashboardResource, "", "5"),
|
||||
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "10"),
|
||||
common.NewResourceTuple("user:10", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "11"),
|
||||
common.NewGroupResourceTuple("user:11", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource),
|
||||
common.NewFolderResourceTuple("user:12", common.RelationGet, dashboardGroup, dashboardResource, statusSubresource, "5"),
|
||||
common.NewFolderResourceTuple("user:13", common.RelationGet, folderGroup, folderResource, statusSubresource, "5"),
|
||||
common.NewTypedResourceTuple("user:14", common.RelationGet, common.TypeTeam, teamGroup, teamResource, statusSubresource, "1"),
|
||||
common.NewTypedResourceTuple("user:15", common.RelationGet, common.TypeUser, userGroup, userResource, statusSubresource, "1"),
|
||||
common.NewTypedResourceTuple("user:16", common.RelationGet, common.TypeServiceAccount, serviceAccountGroup, serviceAccountResource, statusSubresource, "1"),
|
||||
},
|
||||
}
|
||||
for _, w := range writes.TupleKeys {
|
||||
t.Log(w.String())
|
||||
TupleKeys: tuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
|
||||
// First, try to delete any existing tuples to avoid conflicts
|
||||
@@ -140,16 +173,18 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
}
|
||||
|
||||
// Try to delete existing tuples (ignore errors if they don't exist)
|
||||
_, _ = openfga.Write(context.Background(), &openfgav1.WriteRequest{
|
||||
_, err = srv.openfga.Write(context.Background(), &openfgav1.WriteRequest{
|
||||
StoreId: storeInf.ID,
|
||||
AuthorizationModelId: storeInf.ModelID,
|
||||
Deletes: &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deletes,
|
||||
OnMissing: "ignore",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now write the new tuples
|
||||
_, err = openfga.Write(context.Background(), &openfgav1.WriteRequest{
|
||||
_, err = srv.openfga.Write(context.Background(), &openfgav1.WriteRequest{
|
||||
StoreId: storeInf.ID,
|
||||
AuthorizationModelId: storeInf.ModelID,
|
||||
Writes: writes,
|
||||
|
||||
Reference in New Issue
Block a user