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:
Alexander Zobnin
2025-10-28 11:22:13 +01:00
committed by GitHub
parent 19d88de3cf
commit 7a7fd45bdd
25 changed files with 2108 additions and 502 deletions
@@ -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,