Zanzana: Role write APIs (#114533)

* Zanzana: Role write APIs

* Add tests

* Update pkg/services/authz/zanzana/server/server_mutate_roles.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

* fix func usage

---------

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
Alexander Zobnin
2025-11-28 11:44:58 +01:00
committed by GitHub
co-authored by Gabriel MABILLE
parent 8e7ba60b93
commit 358d0eb266
7 changed files with 878 additions and 397 deletions
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,8 @@ message MutateOperation {
DeleteRoleBindingOperation delete_role_binding = 9;
CreateTeamBindingOperation create_team_binding = 10;
DeleteTeamBindingOperation delete_team_binding = 11;
CreateRoleOperation create_role = 12;
DeleteRoleOperation delete_role = 13;
}
}
@@ -131,6 +133,29 @@ message DeleteTeamBindingOperation {
string permission = 3;
}
message CreateRoleOperation {
// kind of the role (Role/CoreRole/GlobalRole)
string role_kind = 1;
// uid of the role
string role_name = 2;
// permissions of the role
repeated RolePermission permissions = 3;
}
message DeleteRoleOperation {
// kind of the role (Role/CoreRole/GlobalRole)
string role_kind = 1;
// uid of the role
string role_name = 2;
// permissions of the role
repeated RolePermission permissions = 3;
}
message RolePermission {
string action = 1;
string scope = 2;
}
message Resource {
// group of the resource (e.g: "dashboard.grafana.app")
string group = 1;
@@ -447,6 +447,14 @@ func ToOpenFGATuples(tuples []*authzextv1.Tuple) []*openfgav1.Tuple {
return result
}
func ToOpenFGADeleteTupleKey(tuples *openfgav1.TupleKey) *openfgav1.TupleKeyWithoutCondition {
return &openfgav1.TupleKeyWithoutCondition{
User: tuples.GetUser(),
Relation: tuples.GetRelation(),
Object: tuples.GetObject(),
}
}
func AddRenderContext(req *openfgav1.CheckRequest) {
if req.ContextualTuples == nil {
req.ContextualTuples = &openfgav1.ContextualTupleKeys{}
@@ -17,6 +17,7 @@ const (
OperationGroupUserOrgRole OperationGroup = "user_org_role"
OperationGroupRoleBinding OperationGroup = "role_binding"
OperationGroupTeamBinding OperationGroup = "team_binding"
OperationGroupRole OperationGroup = "role"
)
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
@@ -73,6 +74,10 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
if err := s.mutateTeamBindings(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate team bindings: %w", err)
}
case OperationGroupRole:
if err := s.mutateRoles(ctx, storeInf, operations); err != nil {
return nil, fmt.Errorf("failed to mutate roles: %w", err)
}
default:
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
}
@@ -93,6 +98,8 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e
return OperationGroupRoleBinding, nil
case *authzextv1.MutateOperation_CreateTeamBinding, *authzextv1.MutateOperation_DeleteTeamBinding:
return OperationGroupTeamBinding, nil
case *authzextv1.MutateOperation_CreateRole, *authzextv1.MutateOperation_DeleteRole:
return OperationGroupRole, nil
}
return OperationGroup(""), errors.New("unsupported mutate operation type")
}
@@ -0,0 +1,108 @@
package server
import (
"context"
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func (s *Server) mutateRoles(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
ctx, span := s.tracer.Start(ctx, "server.mutateRoles")
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_CreateRole:
tuples, err := convertRoleToTuples(op.CreateRole.RoleName, op.CreateRole.Permissions)
if err != nil {
return err
}
writeTuples = append(writeTuples, tuples...)
case *authzextv1.MutateOperation_DeleteRole:
tuples, err := convertRoleToTuples(op.DeleteRole.RoleName, op.DeleteRole.Permissions)
if err != nil {
return err
}
deletes := make([]*openfgav1.TupleKeyWithoutCondition, 0, len(tuples))
for _, tuple := range tuples {
deletes = append(deletes, common.ToOpenFGADeleteTupleKey(tuple))
}
deleteTuples = append(deleteTuples, deletes...)
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 role binding tuples", "error", err)
return err
}
return nil
}
// convertRoleToTuples converts role and its permissions (action/scope) to v1 TupleKey format
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
func convertRoleToTuples(roleUID string, permissions []*authzextv1.RolePermission) ([]*openfgav1.TupleKey, error) {
// Convert to zanzana.RolePermission
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
for _, perm := range permissions {
// Split the scope to get kind, attribute, identifier
kind, _, identifier := splitScope(perm.Scope)
rolePerms = append(rolePerms, zanzana.RolePermission{
Action: perm.Action,
Kind: kind,
Identifier: identifier,
})
}
// Translate to Zanzana tuples
tuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
if err != nil {
return nil, err
}
return tuples, nil
}
func splitScope(scope string) (string, string, string) {
if scope == "" {
return "", "", ""
}
fragments := strings.Split(scope, ":")
switch l := len(fragments); l {
case 1: // Splitting a wildcard scope "*" -> kind: "*"; attribute: "*"; identifier: "*"
return fragments[0], fragments[0], fragments[0]
case 2: // Splitting a wildcard scope with specified kind "dashboards:*" -> kind: "dashboards"; attribute: "*"; identifier: "*"
return fragments[0], fragments[1], fragments[1]
default: // Splitting a scope with all fields specified "dashboards:uid:my_dash" -> kind: "dashboards"; attribute: "uid"; identifier: "my_dash"
return fragments[0], fragments[1], strings.Join(fragments[2:], ":")
}
}
@@ -0,0 +1,77 @@
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 setupMutateRoles(t *testing.T, srv *Server) *Server {
t.Helper()
// seed tuples
tuples := []*openfgav1.TupleKey{
common.NewTuple("role:foo_viewer#assignee", "view", "group_resource:dashboard.grafana.app/dashboards"),
}
return setupOpenFGADatabase(t, srv, tuples)
}
func testMutateRoles(t *testing.T, srv *Server) {
setupMutateRoles(t, srv)
t.Run("should update role and delete old role permissions", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_CreateRole{
CreateRole: &v1.CreateRoleOperation{
RoleName: "foo_viewer",
RoleKind: "Role",
Permissions: []*v1.RolePermission{
{
Action: "dashboards:edit",
Scope: "dashboards:*",
},
},
},
},
},
{
Operation: &v1.MutateOperation_DeleteRole{
DeleteRole: &v1.DeleteRoleOperation{
RoleName: "foo_viewer",
RoleKind: "Role",
Permissions: []*v1.RolePermission{
{
Action: "dashboards:view",
Scope: "dashboards:*",
},
},
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
User: "role:foo_viewer#assignee",
Relation: "edit",
Object: "group_resource:",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "role:foo_viewer#assignee", res.Tuples[0].Key.User)
require.Equal(t, "group_resource:dashboard.grafana.app/dashboards", res.Tuples[0].Key.Object)
require.Equal(t, "edit", res.Tuples[0].Key.Relation)
})
}
@@ -144,6 +144,10 @@ func TestIntegrationServer(t *testing.T) {
t.Run("test mutate team bindings", func(t *testing.T) {
testMutateTeamBindings(t, srv)
})
t.Run("test mutate roles", func(t *testing.T) {
testMutateRoles(t, srv)
})
}
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {