Code Diff
diff --git a/pkg/services/authz/zanzana/server/reconciler/translators_test.go b/pkg/services/authz/zanzana/server/reconciler/translators_test.go
index 021289cf4b6e2..41334ed78934e 100644
--- a/pkg/services/authz/zanzana/server/reconciler/translators_test.go
+++ b/pkg/services/authz/zanzana/server/reconciler/translators_test.go
@@ -690,10 +690,63 @@ func TestTranslateRoleToTuplesWithComposition(t *testing.T) {
})
}
+// TestTranslateRoleToTuples_RoleManagementPermissions verifies the reconciler
+// end-to-end (Role CRD → tuples) for the three legacy role-management actions:
+//
+// - roles:write + permissions:type:delegate → edit on group_resource:.../roles
+// - roles:delete + permissions:type:delegate → delete on group_resource:.../roles
+// - roles:read + roles:* → get on both .../roles and .../globalroles
+//
+// This is the reconciler-facing contract: when a Role with these permissions
+// is reconciled, the resulting tuples must let the bound principal exercise
+// the IAM roles/globalroles APIs.
+func TestTranslateRoleToTuples_RoleManagementPermissions(t *testing.T) {
+ role := &iamv0.Role{
+ ObjectMeta: metav1.ObjectMeta{Name: "role-admin"},
+ Spec: iamv0.RoleSpec{
+ Permissions: []iamv0.RolespecPermission{
+ {Action: "roles:read", Scope: "roles:*"},
+ {Action: "roles:write", Scope: "permissions:type:delegate"},
+ {Action: "roles:delete", Scope: "permissions:type:delegate"},
+ },
+ },
+ }
+
+ tuples, err := TranslateRoleToTuples(toUnstructured(t, role))
+ require.NoError(t, err)
+
+ require.ElementsMatch(t, tupleKeyStrings([]*openfgav1.TupleKey{
+ {User: "role:role-admin#assignee", Relation: "get", Object: "group_resource:iam.grafana.app/roles"},
+ {User: "role:role-admin#assignee", Relation: "get", Object: "group_resource:iam.grafana.app/globalroles"},
+ {User: "role:role-admin#assignee", Relation: "edit", Object: "group_resource:iam.grafana.app/roles"},
+ {User: "role:role-admin#assignee", Relation: "delete", Object: "group_resource:iam.grafana.app/roles"},
+ }), tupleKeyStrings(tuples))
+
+ // Every reconciled tuple must conform to the FGA model — catches drift
+ // between the helper output and the schema (e.g. unknown relation, wrong
+ // user type) before it reaches Zanzana.
+ ts := loadTypesystem(t)
+ for _, tu := range tuples {
+ validateTupleAgainstSchema(t, ts, tu)
+ }
+}
+
// ---------------------------------------------------------------------------
// Schema validation: verify that translated tuples conform to the FGA model.
// ---------------------------------------------------------------------------
+// tupleKeyStrings returns the prototext (`.String()`) form of each tuple.
+// Comparing tuples by their textual form sidesteps proto-internal state
+// caches that confuse reflect-based comparators like require.ElementsMatch,
+// and automatically picks up any new public field added to TupleKey upstream.
+func tupleKeyStrings(tuples []*openfgav1.TupleKey) []string {
+ out := make([]string, len(tuples))
+ for i, t := range tuples {
+ out[i] = t.String()
+ }
+ return out
+}
+
// loadTypesystem loads the Zanzana FGA schema modules and creates a TypeSystem
// that can be used to validate tuples against the authorization model.
func loadTypesystem(t *testing.T) *typesystem.TypeSystem {
diff --git a/pkg/services/authz/zanzana/tuple_helpers.go b/pkg/services/authz/zanzana/tuple_helpers.go
index b5171a80acda7..73329097e19b8 100644
--- a/pkg/services/authz/zanzana/tuple_helpers.go
+++ b/pkg/services/authz/zanzana/tuple_helpers.go
@@ -20,6 +20,29 @@ var (
errUnknownKind = errors.New("unknown permission kind")
)
+// Legacy role-management actions. Kept here (instead of importing acmodels) to
+// avoid an extension/legacy package dependency from the shared zanzana helpers.
+const (
+ actionRolesRead = "roles:read"
+ actionRolesWrite = "roles:write"
+ actionRolesDelete = "roles:delete"
+)
+
+// Scope fragments produced by splitScope for the two "all roles" scopes we
+// honor when translating role-management permissions:
+// - permissions:type:delegate → kind="permissions", identifier="delegate"
+// - roles:* → kind="roles", identifier="*"
+//
+// The K8s mapper for roles already treats `permissions:type:delegate` as
+// equivalent to `roles:*` (see pkg/services/authz/rbac/mapper.go), so both
+// scope shapes resolve to the same wildcard group_resource tuple here.
+const (
+ scopeKindPermissions = "permissions"
+ scopeIdentifierDelegate = "delegate"
+ scopeKindRoles = "roles"
+ scopeIdentifierWildcard = "*"
+)
+
// TupleStringWithoutCondition returns the string representation of a tuple without its condition.
// This is useful for deduplicating tuples that have the same user, relation, and object
// but different conditions that need to be merged.
@@ -58,6 +81,19 @@ func ConvertRolePermissionsToTuples(roleUID string, permissions []RolePermission
folderResourceTuples := make(map[string]*openfgav1.TupleKey) // key is tuple without condition
for _, perm := range permissions {
+ // Role-management actions (roles:read/write/delete) take a dedicated
+ // translation path — their scope kinds (permissions:type:delegate,
+ // roles:*) aren't in the standard resource translation table, so
+ // falling through to TranslateToResourceTuple would log a misleading
+ // "can't translate" message. Non-wildcard scopes on role-management
+ // actions are intentionally dropped (see RoleManagementToTuples).
+ if isRoleManagementAction(perm.Action) {
+ for _, t := range RoleManagementToTuples(subject, perm) {
+ tupleMap[t.String()] = t
+ }
+ continue
+ }
+
// Convert RBAC action/kind to Zanzana tuple
tuple, ok := TranslateToResourceTuple(subject, perm.Action, perm.Kind, perm.Identifier)
if !ok {
@@ -119,6 +155,89 @@ func RoleToTuples(roleUID string, permissions []*authzextv1.RolePermission) ([]*
return tuples, nil
}
+// RoleManagementToTuples returns the Zanzana tuples that grant a role the
+// ability to manage other roles in the namespace and/or read global roles,
+// based on its legacy role-management permissions.
+//
+// The legacy RBAC layer scopes role-management actions in two equivalent
+// shapes that both mean "any role" — `permissions:type:delegate` (the
+// canonical form for write/delete) and `roles:*` (the wildcard form used by
+// `roles:read`). We treat the two as interchangeable here, matching the
+// rbac mapper's behavior.
+//
+// Only wildcard-scoped permissions produce tuples. The FGA schema for
+// `iam.grafana.app` only exposes a `group_resource` type — there is no
+// per-role instance type — so a permission scoped to a specific role
+// (e.g. `roles:uid:<specific>`) cannot be expressed in Zanzana without
+// silently broadening the grant to all roles, and is therefore dropped.
+//
+// Mapping (subject is `role:<roleUID>#assignee`, wildcard scope required):
+//
+// - `roles:read` → `get` on group_resource:iam.grafana.app/roles AND
+// `get` on group_resource:iam.grafana.app/globalroles
+// (the legacy `roles:read` action covers both APIs — see
+// pkg/services/authz/rbac/mapper.go, where `globalroles` is wired with
+// `useWildcardScope: true`).
+//
+// - `roles:write` → `edit` on group_resource:iam.grafana.app/roles.
+// `edit` is used (instead of `update`) because the FGA schema defines
+// create/update/delete on group_resource as `... or edit`, and the legacy
+// `roles:write` action covers create + update + patch + delete.
+//
+// - `roles:delete` → `delete` on group_resource:iam.grafana.app/roles.
+func RoleManagementToTuples(subject string, permission RolePermission) []*openfgav1.TupleKey {
+ rolesGroup := iamv0.RoleInfo.GroupResource().Group
+ rolesResource := iamv0.RoleInfo.GroupResource().Resource
+ globalRolesResource := iamv0.GlobalRoleInfo.GroupResource().Resource
+
+ if !isRolesWildcardScope(permission.Kind, permission.Identifier) {
+ return nil
+ }
+
+ var tuples []*openfgav1.TupleKey
+ switch permission.Action {
+ case actionRolesRead:
+ tuples = append(tuples,
+ NewGroupResourceTuple(subject, RelationGet, rolesGroup, globalRolesResource, ""),
+ NewGroupResourceTuple(subject, RelationGet, rolesGroup, rolesResource, ""),
+ )
+ case actionRolesWrite:
+ tuples = append(tuples,
+ NewGroupResourceTuple(subject, RelationSetEdit, rolesGroup, rolesResource, ""),
+ )
+ case actionRolesDelete:
+ tuples = append(tuples,
+ NewGroupResourceTuple(subject, RelationDelete, rolesGroup, rolesResource, ""),
+ )
+ }
+ return tuples
+}
+
+// isRoleManagementAction reports whether the action is one of the legacy
+// role-management actions (roles:read/write/delete). These take a dedicated
+// translation path in ConvertRolePermissionsToTuples — see RoleManagementToTuples.
+func isRoleManagementAction(action string) bool {
+ switch action {
+ case actionRolesRead, actionRolesWrite, actionRolesDelete:
+ return true
+ }
+ return false
+}
+
+// isRolesWildcardScope reports whether the (kind, identifier) pair represents
+// an "all roles" scope. Both legacy shapes resolve to true:
+// - permissions:type:delegate → kind="permissions", identifier="delegate"
+// - roles:* → kind="roles", identifier="*"
+func isRolesWildcardScope(kind, identifier string) bool {
+ if kind == scopeKindPermissions && identifier == scopeIdentifierDelegate {
+ return true
+ }
+ if kind == scopeKindRoles && identifier == scopeIdentifierWildcard {
+ return true
+ }
+ return false
+}
+
func splitScope(scope string) (string, string, string) {
if scope == "" {
return "", "", ""
diff --git a/pkg/services/authz/zanzana/tuple_helpers_test.go b/pkg/services/authz/zanzana/tuple_helpers_test.go
index 4da5490911a30..199a99616ec97 100644
--- a/pkg/services/authz/zanzana/tuple_helpers_test.go
+++ b/pkg/services/authz/zanzana/tuple_helpers_test.go
@@ -142,4 +142,55 @@ func TestConvertRolePermissionsToTuples(t *testing.T) {
require.Equal(t, "get", tuples[0].Relation)
require.Equal(t, "folder:folder1", tuples[0].Object)
})
+
+ t.Run("should reconcile role-management permissions", func(t *testing.T) {
+ // A typical "Grafana Admin" set of role-management permissions: read all roles,
+ // write any role (delegated), and delete any role (delegated). The reconciler
+ // must emit one group_resource tuple per IAM resource so the bound principal
+ // can act on the iam.grafana.app/{roles,globalroles} APIs.
+ permissions := []RolePermission{
+ {Action: "roles:read", Kind: "roles", Identifier: "*"},
+ {Action: "roles:write", Kind: "permissions", Identifier: "delegate"},
+ {Action: "roles:delete", Kind: "permissions", Identifier: "delegate"},
+ }
+
+ tuples, err := ConvertRolePermissionsToTuples("role-admin", permissions)
+ require.NoError(t, err)
+
+ require.ElementsMatch(t, tupleKeyStrings([]*openfgav1.TupleKey{
+ {User: "role:role-admin#assignee", Relation: "get", Object: "group_resource:iam.grafana.app/roles"},
+ {User: "role:role-admin#assignee", Relation: "get", Object: "group_resource:iam.grafana.app/globalroles"},
+ {User: "role:role-admin#assignee", Relation: "edit", Object: "group_resource:iam.grafana.app/roles"},
+ {User: "role:role-admin#assignee", Relation: "delete", Object: "group_resource:iam.grafana.app/roles"},
+ }), tupleKeyStrings(tuples))
+ })
+
+ t.Run("scoped role-management permissions are dropped", func(t *testing.T) {
+ // A scoped role-management permission (e.g. roles:uid:specific) cannot
+ // be expressed in Zanzana — the FGA schema for iam.grafana.app only
+ // exposes group_resource, not a per-instance resource. Translating
+ // these would silently broaden the grant to all roles, so we drop them
+ // entirely (read, write, and delete all behave the same).
+ permissions := []RolePermission{
+ {Action: "roles:read", Kind: "roles", Identifier: "specific-role"},
+ {Action: "roles:write", Kind: "roles", Identifier: "specific-role"},
+ {Action: "roles:delete", Kind: "roles", Identifier: "specific-role"},
+ }
+
+ tuples, err := ConvertRolePermissionsToTuples("role-scoped", permissions)
+ require.NoError(t, err)
+ require.Empty(t, tuples)
+ })
+}
+
+// tupleKeyStrings returns the prototext (`.String()`) form of each tuple.
+// Comparing tuples by their textual form sidesteps proto-internal state
+// caches that confuse reflect-based comparators like require.ElementsMatch,
+// and automatically picks up any new public field added to TupleKey upstream.
+func tupleKeyStrings(tuples []*openfgav1.TupleKey) []string {
+ out := make([]string, len(tuples))
+ for i, t := range tuples {
+ out[i] = t.String()
+ }
+ return out
}
diff --git a/pkg/services/authz/zanzana/zanzana.go b/pkg/services/authz/zanzana/zanzana.go
index e9d7eecba41a5..fdec2ddd8c925 100644
--- a/pkg/services/authz/zanzana/zanzana.go
+++ b/pkg/services/authz/zanzana/zanzana.go
@@ -76,6 +76,7 @@ var (
NewTupleEntry = common.NewTupleEntry
NewObjectEntry = common.NewObjectEntry
+ NewGroupResourceTuple = common.NewGroupResourceTuple
TranslateToResourceTuple = common.TranslateToResourceTuple
IsFolderResourceTuple = common.IsFolderResourceTuple
MergeFolderResourceTuples = common.MergeFolderResourceTuples