Zanzana: Write API for org roles (#113339)

* Zanzana: Add write APIs for user org roles

* Add tests

* Fix tests

* fix role translation
This commit is contained in:
Alexander Zobnin
2025-11-03 14:47:10 +01:00
committed by GitHub
parent a89377337b
commit d6fa822e89
7 changed files with 664 additions and 306 deletions
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,8 @@ message MutateOperation {
DeleteFolderOperation delete_folder = 2;
CreatePermissionOperation create_permission = 3;
DeletePermissionOperation delete_permission = 4;
UpdateUserOrgRoleOperation update_user_org_role = 5;
DeleteUserOrgRoleOperation delete_user_org_role = 6;
}
}
@@ -61,6 +63,20 @@ message DeletePermissionOperation {
Permission permission = 2;
}
message UpdateUserOrgRoleOperation {
// User UID
string user = 1;
// Role name (e.g: "Admin", "Editor", "Viewer")
string role = 2;
}
message DeleteUserOrgRoleOperation {
// User UID
string user = 1;
// Role name (e.g: "Admin", "Editor", "Viewer")
string role = 2;
}
message Resource {
// group of the resource (e.g: "dashboard.grafana.app")
string group = 1;
@@ -350,6 +350,14 @@ func NewTypedTuple(typ, subject, relation, name string) *openfgav1.TupleKey {
}
}
func NewTuple(subject, relation, object string) *openfgav1.TupleKey {
return &openfgav1.TupleKey{
User: subject,
Relation: relation,
Object: object,
}
}
func ToAuthzExtTupleKey(t *openfgav1.TupleKey) *authzextv1.TupleKey {
tupleKey := &authzextv1.TupleKey{
User: t.GetUser(),
@@ -12,8 +12,9 @@ import (
type OperationGroup string
const (
OperationGroupFolder OperationGroup = "folder"
OperationGroupPermission OperationGroup = "permission"
OperationGroupFolder OperationGroup = "folder"
OperationGroupPermission OperationGroup = "permission"
OperationGroupUserOrgRole OperationGroup = "user_org_role"
)
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
@@ -58,6 +59,10 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
if err := s.mutateResourcePermissions(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate resource permissions: %w", err)
}
case OperationGroupUserOrgRole:
if err := s.mutateOrgRoles(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate org roles: %w", err)
}
default:
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
}
@@ -72,6 +77,8 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e
return OperationGroupFolder, nil
case *authzextv1.MutateOperation_CreatePermission, *authzextv1.MutateOperation_DeletePermission:
return OperationGroupPermission, nil
case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole:
return OperationGroupUserOrgRole, nil
}
return OperationGroup(""), errors.New("unsupported mutate operation type")
}
@@ -0,0 +1,84 @@
package server
import (
"context"
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) mutateOrgRoles(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
ctx, span := s.tracer.Start(ctx, "server.mutateOrgRoles")
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_UpdateUserOrgRole:
tuple, err := s.getUserOrgRoleWriteTuple(ctx, store, op.UpdateUserOrgRole)
if err != nil {
return err
}
writeTuples = append(writeTuples, tuple)
case *authzextv1.MutateOperation_DeleteUserOrgRole:
tuple, err := s.getUserOrgRoleDeleteTuple(ctx, store, op.DeleteUserOrgRole)
if err != nil {
return err
}
deleteTuples = append(deleteTuples, tuple)
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 user org role tuples", "error", err)
return err
}
return nil
}
func (s *Server) getUserOrgRoleWriteTuple(ctx context.Context, store *storeInfo, req *authzextv1.UpdateUserOrgRoleOperation) (*openfgav1.TupleKey, error) {
basicRole := zanzana.TranslateBasicRole(req.GetRole())
return &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
}, nil
}
func (s *Server) getUserOrgRoleDeleteTuple(ctx context.Context, store *storeInfo, req *authzextv1.DeleteUserOrgRoleOperation) (*openfgav1.TupleKeyWithoutCondition, error) {
basicRole := zanzana.TranslateBasicRole(req.GetRole())
return &openfgav1.TupleKeyWithoutCondition{
User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
}, nil
}
@@ -0,0 +1,72 @@
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 setupMutateOrgRoles(t *testing.T, srv *Server) *Server {
t.Helper()
// seed tuples
tuples := []*openfgav1.TupleKey{
common.NewTuple("user:1", common.RelationAssignee, "role:basic_editor"),
}
return setupOpenFGADatabase(t, srv, tuples)
}
func testMutateOrgRoles(t *testing.T, srv *Server) {
setupMutateOrgRoles(t, srv)
t.Run("should update user org role and delete old role", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_UpdateUserOrgRole{
UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{
User: "1",
Role: "Admin",
},
},
},
{
Operation: &v1.MutateOperation_DeleteUserOrgRole{
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{
User: "1",
Role: "Editor",
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationAssignee,
Object: "role:basic_admin",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "user:1", res.Tuples[0].Key.User)
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationAssignee,
Object: "role:basic_editor",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
}
@@ -128,6 +128,10 @@ func TestIntegrationServer(t *testing.T) {
t.Run("test mutate resource permissions", func(t *testing.T) {
testMutateResourcePermissions(t, srv)
})
t.Run("test mutate org roles", func(t *testing.T) {
testMutateOrgRoles(t, srv)
})
}
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {