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) {