Access control / Privilege escalation via RBAC scope UID collision for alert rules

MEDIUM
grafana/grafana
Commit: 848529be6040
Affected: < 12.4.0
2026-07-17 15:16 UTC

Description

The commit adds an RBAC translation for alert rule resources under rules.alerting.grafana.app. It introduces a per-object direct-scope for alert rules using the resource name alert.rules (prefix) and the UID, instead of a potential folders:uid:<uid> path. This, combined with folder inheritance (folderSupport: true), prevents a collision between rule UIDs and folder UIDs from causing an authorization bypass. Prior behavior could map per-object scope to a folder UID, potentially allowing access to alert rule resources when a user had folder-scoped permissions for a folder whose UID collided with a rule UID. The change is a genuine security fix to RBAC behavior for alert rules, not merely a dependency bump or a test addition. The changes are focused on authorization routing for alert rule resources (alertrules, recordingrules, rulesequences) and their mapping to alert.rules:* actions, with a dedicated per-object scope prefix to avoid collisions with folders.

Proof of Concept

PoC (conceptual, executable steps depend on Grafana RBAC API endpoints in a given deployment): Assumptions: - Grafana RBAC is enabled. - You have two identities: Admin (full access) and UserA (only folder-level access). - A folder exists with uid: collidingUID and UserA has view access to that folder (folders:view for collidingUID). - An alert rule resource also exists with uid: collidingUID under rules.alerting.grafana.app. Goal: Demonstrate potential pre-fix privilege escalation where a user with only folder permissions could read the alert rule with the same UID as a folder due to a collision in per-object scope. Before fix (hypothetical): The per-object scope for alert rule resources could be derived as folders:uid:<uid> when checking access to the rule resource. If UserA has a folders:view grant on folder collidingUID, a request to read the alert rule with uid collidingUID could be approved. After fix (as implemented in the commit): The per-object scope is alert.rules:uid:<uid>, which no existing folder grant would ever grant, so the folder-only permission cannot authorize access to the alert rule directly; only folder inheritance grants apply. Steps (illustrative; endpoints depend on Grafana deployment): 1) Admin creates a folder with uid 'collidingUID' and grants UserA view permission on that folder. 2) Admin creates an alert rule resource (alertrules/recordingrules/rulesequences) with uid 'collidingUID' under rules.alerting.grafana.app. 3) Admin retrieves the alert rule as Admin to confirm baseline access: curl -H 'Authorization: Bearer ADMIN_TOKEN' https://grafana.example/api/rules/alerting/grafana.app/collidingUID (expect 200) 4) UserA attempts to read the same alert rule: curl -H 'Authorization: Bearer USERA_TOKEN' https://grafana.example/api/rules/alerting/grafana.app/collidingUID (pre-fix: potential 200 if per-object scope incorrectly matched by folder grant; post-fix: should be 403) Expected results: - Pre-fix: If the system computed per-object scope as folders:uid:<uid>, UserA could gain read access to the alert rule due to the folder grant. - Post-fix: With alias per-object scope alert.rules:uid:<uid>, the access is governed by folder inheritance only, and colliding UID does not grant unintended access; the read should be denied if UserA has no explicit folder-based access to that alert rule resource. Notes: - The exact API endpoints for alert rule resources depend on Grafana’s RBAC resource API in your deployment. Replace the placeholders with the actual endpoints when validating in your environment. - The PoC demonstrates the conceptual regression that the fix addresses and how you would test for it in a live system.

Commit Details

Author: Fayzal Ghantiwala

Date: 2026-07-17 14:36 UTC

Message:

authz: Add RBAC mapper support for rules.alerting.grafana.app rule resources (#128579) * Add new alert rule translation layer * prevent folderuid/ruleuid collisions

Triage Assessment

Vulnerability Type: Access control / Privilege escalation

Confidence: MEDIUM

Reasoning:

The commit adds RBAC translation for alert rule resources under rules.alerting.grafana.app, tying them to folder-scoped alert.rules actions and introducing a per-object direct-scope prefix to avoid collisions with folder grants. This addresses potential authorization bypass risks where rule UIDs could collide with folder UIDs, leading to unintended access. The changes are focused on access control mappings rather than feature or UI changes, indicating a security-related fix to RBAC behavior.

Verification Assessment

Vulnerability Type: Access control / Privilege escalation via RBAC scope UID collision for alert rules

Confidence: MEDIUM

Affected Versions: < 12.4.0

Code Diff

diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index c374f68eee153..53d63d60a638c 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -223,6 +223,7 @@ var serviceIdentityTokenPermissions = []string{ "plugins.grafana.app:*", "historian.alerting.grafana.app:*", "notifications.alerting.grafana.app:*", + "rules.alerting.grafana.app:*", "advisor.grafana.app:*", "annotation.grafana.app:*", diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index 56aa2188d0e80..402bc949115a1 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -312,6 +312,62 @@ func newAlertmanagerImportsTranslation() translation { } } +// newAlertRuleTranslation maps the rule resources in rules.alerting.grafana.app +// (alertrules, recordingrules, rulesequences) to the alert.rules:* actions. +// +// Alert-rule permissions are always folder-scoped: they are granted on the folder +// scope (folders:uid:<uid>), never on a per-rule scope. Authorization therefore +// flows entirely through folder inheritance (folderSupport: true), which the check +// path evaluates against the object's parent folder using a hardcoded folders:uid: +// prefix — independent of this translation's resource. +// +// The resource is deliberately "alert.rules" (not "folders"). The direct-scope +// check builds Scope(name) from the request's object name (the rule UID); with a +// "folders" resource that would produce folders:uid:<ruleUID>, which could collide +// with a real folder grant when a rule UID happens to equal a folder UID and grant +// unintended access. Using "alert.rules" yields alert.rules:uid:<ruleUID>, a scope +// no grant ever has, so the direct-scope check is a guaranteed no-op and only the +// folder-inheritance path decides access. +// +// The alert.rules:* actions are also part of the folder view/edit/admin action +// sets, so users granted via managed folder roles are matched too. +func newAlertRuleTranslation() translation { + t := translation{ + // See doc comment: "alert.rules" makes the per-object direct-scope check a + // no-op; folder inheritance (below) does the real authorization. + resource: "alert.rules", + attribute: "uid", + verbMapping: map[string]string{ + utils.VerbGet: accesscontrol.ActionAlertingRuleRead, + utils.VerbList: accesscontrol.ActionAlertingRuleRead, + utils.VerbWatch: accesscontrol.ActionAlertingRuleRead, + utils.VerbCreate: accesscontrol.ActionAlertingRuleCreate, + utils.VerbUpdate: accesscontrol.ActionAlertingRuleUpdate, + utils.VerbPatch: accesscontrol.ActionAlertingRuleUpdate, + utils.VerbDelete: accesscontrol.ActionAlertingRuleDelete, + utils.VerbDeleteCollection: accesscontrol.ActionAlertingRuleDelete, + }, + folderSupport: true, + } + + actionSetMapping := make(map[string][]string) + for verb, rbacAction := range t.verbMapping { + var actionSets []string + if slices.Contains(ossaccesscontrol.FolderViewActions, rbacAction) { + actionSets = append(actionSets, "folders:view") + } + if slices.Contains(ossaccesscontrol.FolderEditActions, rbacAction) { + actionSets = append(actionSets, "folders:edit") + } + if slices.Contains(ossaccesscontrol.FolderAdminActions, rbacAction) { + actionSets = append(actionSets, "folders:admin") + } + actionSetMapping[verb] = actionSets + } + t.actionSetMapping = actionSetMapping + return t +} + func NewMapperRegistry() MapperRegistry { skipScopeOnAllVerbs := map[string]bool{ utils.VerbCreate: true, @@ -331,6 +387,12 @@ func NewMapperRegistry() MapperRegistry { "routingtrees": newRoutingTreeTranslation(), "alertmanagerimports": newAlertmanagerImportsTranslation(), }, + "rules.alerting.grafana.app": { + // All rule resources share the folder-scoped alert.rules:* actions. + "alertrules": newAlertRuleTranslation(), + "recordingrules": newAlertRuleTranslation(), + "rulesequences": newAlertRuleTranslation(), + }, "dashboard.grafana.app": { "dashboards": newDashboardTranslation(), "librarypanels": newResourceTranslation("library.panels", "uid", true, nil), diff --git a/pkg/services/authz/rbac/mapper_test.go b/pkg/services/authz/rbac/mapper_test.go index 36f29f1f6144b..df9402f266d43 100644 --- a/pkg/services/authz/rbac/mapper_test.go +++ b/pkg/services/authz/rbac/mapper_test.go @@ -258,6 +258,71 @@ func TestMapper_ServiceAccountTranslation_ActionSets(t *testing.T) { } } +// TestMapperRegistry_AlertRules verifies the rules.alerting.grafana.app rule +// resources map to the alert.rules:* actions, support folder inheritance, use a +// rules-specific direct-scope prefix (so the per-object check never spuriously +// matches a folder grant), and flow through the folder action sets +// (folders:view/edit/admin) for managed roles. +func TestMapperRegistry_AlertRules(t *testing.T) { + reg := NewMapperRegistry() + + readActionSets := []string{"folders:view", "folders:edit", "folders:admin"} + writeActionSets := []string{"folders:edit", "folders:admin"} + + for _, resource := range []string{"alertrules", "recordingrules", "rulesequences"} { + t.Run(resource, func(t *testing.T) { + mapping, ok := reg.Get("rules.alerting.grafana.app", resource, "") + require.True(t, ok, "%q should be registered in the mapper", resource) + require.NotNil(t, mapping) + + // Alert-rule permissions are folder-scoped via folder inheritance, not via + // the per-object direct scope. The resource is "alert.rules" so Scope() + // yields alert.rules:uid:<name> — a scope no grant ever has — which makes + // the direct-scope check a no-op and avoids colliding a rule UID with a + // folder grant (folders:uid:<uid>). + assert.True(t, mapping.HasFolderSupport(), "alert rules are folder-scoped") + assert.Equal(t, "alert.rules:uid:", mapping.Prefix()) + assert.Equal(t, "alert.rules:uid:abc", mapping.Scope("abc")) + + actionTests := []struct { + verb string + action string + }{ + {utils.VerbGet, "alert.rules:read"}, + {utils.VerbList, "alert.rules:read"}, + {utils.VerbWatch, "alert.rules:read"}, + {utils.VerbCreate, "alert.rules:create"}, + {utils.VerbUpdate, "alert.rules:write"}, + {utils.VerbPatch, "alert.rules:write"}, + {utils.VerbDelete, "alert.rules:delete"}, + {utils.VerbDeleteCollection, "alert.rules:delete"}, + } + for _, tt := range actionTests { + action, ok := mapping.Action(tt.verb) + assert.True(t, ok, "verb %q should map to an action", tt.verb) + assert.Equal(t, tt.action, action, "verb %q", tt.verb) + } + + actionSetTests := []struct { + verb string + expected []string + }{ + {utils.VerbGet, readActionSets}, + {utils.VerbList, readActionSets}, + {utils.VerbWatch, readActionSets}, + {utils.VerbCreate, writeActionSets}, + {utils.VerbUpdate, writeActionSets}, + {utils.VerbPatch, writeActionSets}, + {utils.VerbDelete, writeActionSets}, + {utils.VerbDeleteCollection, writeActionSets}, + } + for _, tt := range actionSetTests { + assert.ElementsMatch(t, tt.expected, mapping.ActionSets(tt.verb), "action sets for verb %q", tt.verb) + } + }) + } +} + // TestMapper_AnnotationSubresource_ActionSets verifies that managed roles (dashboards:view etc.) // flow through to annotation verbs via the subresource action set mapping. func TestMapper_AnnotationSubresource_ActionSets(t *testing.T) {
← Back to Alerts View on GitHub →