Access Control / Authorization

HIGH
grafana/grafana
Commit: 1d6f679a6c47
Affected: Grafana 12.0.0 through 12.3.x (pre-12.4.0)
2026-05-28 21:34 UTC

Description

The commit fixes an authorization mapping issue around legacy role-management permissions (roles:read, roles:write, roles:delete) by introducing explicit reconciliation logic for these permissions into Zanzana tuples. It adds dedicated translation paths (RoleManagementToTuples) and safeguards to drop scoped role-management permissions that cannot be safely expressed (e.g., per-role scoped permissions). The changes also include tests that verify the exact tuple mappings for legacy permissions (get on roles and globalroles for read; edit on roles for write; delete on roles for delete) and ensure that unsafe/scoped permutations are omitted. This reduces the risk of privilege misconfigurations or unintended escalation due to improper translation of legacy RBAC permissions into the Zanzana access-control model.

Proof of Concept

Prerequisites: Grafana with Zanzana (OpenFGA) integration configured and a Role CRD available. A test OpenFGA store is accessible for policy checks. 1) Create or apply a Role resource that contains legacy role-management permissions: - Action: roles:read, Kind: roles, Identifier: * - Action: roles:write, Kind: permissions, Identifier: delegate - Action: roles:delete, Kind: permissions, Identifier: delegate (Role UID: role-admin) 2) Trigger reconciliation so the translator runs ConvertRolePermissionsToTuples / RoleManagementToTuples. 3) Verify the resulting OpenFGA tuples for user bound to the role (assignee): - get on group_resource:iam.grafana.app/roles - get on group_resource:iam.grafana.app/globalroles - edit on group_resource:iam.grafana.app/roles - delete on group_resource:iam.grafana.app/roles 4) Use OpenFGA to check access for the bound user (role-admin#assignee) against the translated tuples: - Check: can GET group_resource:iam.grafana.app/roles ? Expected: allowed - Check: can GET group_resource:iam.grafana.app/globalroles ? Expected: allowed - Check: can EDIT (or equivalent write) group_resource:iam.grafana.app/roles ? Expected: allowed - Check: can DELETE group_resource:iam.grafana.app/roles ? Expected: allowed 5) Optional helper (OpenFGA check example): curl -sS -X POST https://openfga.example/stores/store-id/check \ -H 'Content-Type: application/json' \ -d '{"queries":[{"user":"role-admin#assignee","relation":"get","object":"group_resource:iam.grafana.app/roles"}]}' Response should indicate allowed if the translation is correctly applying the new tuples. Notes: - The PoC relies on an OpenFGA-like policy store wired to Grafana’s Zanzana implementation. The exact Grafana API endpoints for role management are not required for demonstrating the authorization effect; the OpenFGA check validates whether the translated tuples grant the expected permissions.

Commit Details

Author: Gabriel MABILLE

Date: 2026-05-28 21:09 UTC

Message:

Zanzana: Reconcile `roles:*` permissions (#125661) * Zanzana: Reconcile roles:*` permissions * Account for PR feedback Co-authored-by: Mihai Turdean <6640685+mihai-turdean@users.noreply.github.com> --------- Co-authored-by: Mihai Turdean <6640685+mihai-turdean@users.noreply.github.com>

Triage Assessment

Vulnerability Type: Access Control / Authorization

Confidence: HIGH

Reasoning:

The commit adds explicit handling for legacy role-management permissions (roles:read/write/delete) and reconciles their translation into Zanzana tuples. It ensures proper mapping to get/edit/delete rights on group_resource:iam.grafana.app/roles and globalroles, and introduces safeguards (e.g., dropping scoped role-management permissions that cannot be expressed safely). This tightens and corrects access control behavior, mitigating potential privilege/grant misconfigurations and unintended escalation.

Verification Assessment

Vulnerability Type: Access Control / Authorization

Confidence: HIGH

Affected Versions: Grafana 12.0.0 through 12.3.x (pre-12.4.0)

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
← Back to Alerts View on GitHub →