Access Control / Authorization (RBAC) - Folder-scoped authorization for Kubernetes-native resources
Description
The commit implements folder-scoped authorization (RBAC) for Kubernetes-native resources and adds a path to enforce a 'stack role AND folder permission' model for K8s-native resources not present in the mapper. Prior to this fix, such resources could fall back to a K8s-native mapping, which could allow a user with a wildcard/stack-role permission to access resources without proper folder-scoped authorization, potentially enabling privilege escalation or access to folders the user should not reach. The changes explicitly introduce folder-context checks for mapper-miss resources and add tests around K8s-native fallback behavior, effectively closing a gap where folder-scoped permissions were not consistently enforced for K8s-native resources.
Proof of Concept
PoC (before this fix):
- Scenario: A user holds a stack-role permission for a resource that Grafana does not have an explicit mapper entry for (i.e., a K8s-native resource). The user has Action: "unregistered.grafana.app/widgets:get" with Scope: "" (empty stack role) and no explicit folder permissions.
- Objective: Access a K8s-native resource (e.g., widgets) in a folder without having a corresponding folder grant.
- Steps:
1) Create permissions for the user:
- Action: "unregistered.grafana.app/widgets:get"
Scope: "" (stack role)
- No Folder permissions granted (no folders:read/write on any folder).
2) Attempt a Check/Access call against the K8s-native resource without providing a folder context (ParentFolder = "" and Name = "" or attempting a list operation).
3) Observe that, prior to this commit, the RBAC check path could fall back to K8s-native mapping and, depending on the exact internal translation, might grant access based solely on the stack role, potentially bypassing folder-scoped checks.
PoC (after this fix):
- The patch changes the flow to route mapper-miss resources through checkPermissionWithFolderAuthz, requiring a valid folder permission in addition to the stack role. This prevents access to K8s-native resources without an explicit folder grant.
- Steps to reproduce (with the fixed behavior):
1) Use the same permissions as above (Action: "unregistered.grafana.app/widgets:get", Scope: "").
2) Attempt to access a K8s-native resource without a folder grant (ParentFolder = "").
3) The RBAC check should now deny access, unless a corresponding folder permission is present (e.g., folders:read on the parent folder or folders:* for wildcard folder grants).
Code snippet for a minimal PoC invocation (conceptual):
- Construct a check request similar to Grafana's RBAC CheckRequest:
{
Namespace: "org-12",
Subject: "user:test-uid",
Group: "unregistered.grafana.app",
Resource: "widgets",
Verb: "list", // or "get"
Name: "", // no specific object
ParentFolder: "" // no folder context
}
- Call the RBAC check and observe the result. Before the fix, this could return Allowed = true for stack-role users; after the fix, it should require a valid folder grant and return Allowed = false without it.
Note: To reproduce in a real environment, you would need Grafana RBAC configured with a non-mapped group that still has a stack-role permission and attempt to query a K8s-native resource with no folder scope. The key indicator is whether access is allowed without a folder grant prior to the fix.
Commit Details
Author: Costa Alexoglou
Date: 2026-06-18 12:22 UTC
Message:
Folder scope apiextensions (#125982)
* feat: support folder-scoped authz
* chore: remove ff and review comments
* chore: review session
* RBAC: fix alerting mapping (#126394)
* chore: latest checks
* fix: tests
* chore: last review comments
---------
Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
Triage Assessment
Vulnerability Type: Access Control / Authorization (RBAC)
Confidence: HIGH
Reasoning:
The commit introduces folder-scoped authorization (RBAC with folder checks) for Kubernetes-native resources and updates the permission checks to enforce a 'stack role AND folder permission' model. It adds mapping and checks to ensure folder context is considered, preventing wildcard or mis-scoped access from granting unintended permissions. This addresses potential privilege escalation and improper access to folder-scoped resources.
Verification Assessment
Vulnerability Type: Access Control / Authorization (RBAC) - Folder-scoped authorization for Kubernetes-native resources
Confidence: HIGH
Affected Versions: 12.4.0 and earlier
Code Diff
diff --git a/pkg/services/authz/rbac.go b/pkg/services/authz/rbac.go
index ddc47ddd03983..19b48f4b175c0 100644
--- a/pkg/services/authz/rbac.go
+++ b/pkg/services/authz/rbac.go
@@ -85,7 +85,9 @@ func ProvideAuthZClient(
return rbacClient, nil
default:
sql := legacysql.NewDatabaseProvider(db)
- rbacSettings := rbac.Settings{CacheTTL: authCfg.cacheTTL}
+ rbacSettings := rbac.Settings{
+ CacheTTL: authCfg.cacheTTL,
+ }
if cfg != nil {
rbacSettings.AnonOrgRole = cfg.Anonymous.OrgRole
}
@@ -320,7 +322,10 @@ func RegisterRBACAuthZService(
tracer,
reg,
cache,
- rbac.Settings{CacheTTL: cfg.CacheTTL, LocalFolderCacheTTL: cfg.LocalFolderCacheTTL}, // anonymous org role can only be set in-proc
+ rbac.Settings{
+ CacheTTL: cfg.CacheTTL,
+ LocalFolderCacheTTL: cfg.LocalFolderCacheTTL,
+ }, // anonymous org role can only be set in-proc
)
srv := handler.GetServer()
diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go
index 4549188828112..56aa2188d0e80 100644
--- a/pkg/services/authz/rbac/mapper.go
+++ b/pkg/services/authz/rbac/mapper.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
)
@@ -243,6 +244,74 @@ func newServiceAccountTranslation() translation {
return saTranslation
}
+// newRoutingTreeTranslation maps the notifications.alerting.grafana.app
+// routingtrees resource to the granular, per-resource managed route actions
+// (notifications.alerting.grafana.app/routingtrees:get, ...:uid:<name>) and to
+// the view/edit/admin action sets registered by RoutePermissionsService.
+func newRoutingTreeTranslation() translation {
+ verbMapping := map[string]string{
+ utils.VerbGet: accesscontrol.ActionAlertingManagedRoutesRead,
+ utils.VerbList: accesscontrol.ActionAlertingManagedRoutesRead,
+ utils.VerbWatch: accesscontrol.ActionAlertingManagedRoutesRead,
+ utils.VerbCreate: accesscontrol.ActionAlertingManagedRoutesCreate,
+ utils.VerbUpdate: accesscontrol.ActionAlertingManagedRoutesWrite,
+ utils.VerbPatch: accesscontrol.ActionAlertingManagedRoutesWrite,
+ utils.VerbDelete: accesscontrol.ActionAlertingManagedRoutesDelete,
+ utils.VerbDeleteCollection: accesscontrol.ActionAlertingManagedRoutesDelete,
+ utils.VerbGetPermissions: accesscontrol.ActionAlertingRoutesPermissionsWrite,
+ utils.VerbSetPermissions: accesscontrol.ActionAlertingRoutesPermissionsRead,
+ }
+
+ const (
+ viewSet = "notifications.alerting.grafana.app/routingtrees:view"
+ editSet = "notifications.alerting.grafana.app/routingtrees:edit"
+ adminSet = "notifications.alerting.grafana.app/routingtrees:admin"
+ )
+
+ actionSetMapping := make(map[string][]string)
+ for verb, action := range verbMapping {
+ switch {
+ case slices.Contains(ossaccesscontrol.RoutesViewActions, action):
+ actionSetMapping[verb] = []string{viewSet, editSet, adminSet}
+ case slices.Contains(ossaccesscontrol.RoutesEditActions, action):
+ actionSetMapping[verb] = []string{editSet, adminSet}
+ case slices.Contains(ossaccesscontrol.RoutesAdminActions, action):
+ actionSetMapping[verb] = []string{adminSet}
+ }
+ }
+
+ return translation{
+ resource: accesscontrol.AlertingRoutesKind,
+ attribute: "uid",
+ verbMapping: verbMapping,
+ actionSetMapping: actionSetMapping,
+ folderSupport: false,
+ skipScopeOnVerb: map[string]bool{utils.VerbCreate: true},
+ }
+}
+
+// newAlertmanagerImportsTranslation maps the notifications.alerting.grafana.app
+// alertmanagerimports resource to its granular, per-resource actions. There is
+// no resource permissions service for this resource, so it has no action sets.
+func newAlertmanagerImportsTranslation() translation {
+ return translation{
+ resource: accesscontrol.AlertingAlertmanagerImportsKind,
+ attribute: "uid",
+ verbMapping: map[string]string{
+ utils.VerbGet: accesscontrol.ActionAlertingAlertmanagerImportsRead,
+ utils.VerbList: accesscontrol.ActionAlertingAlertmanagerImportsRead,
+ utils.VerbWatch: accesscontrol.ActionAlertingAlertmanagerImportsRead,
+ utils.VerbCreate: accesscontrol.ActionAlertingAlertmanagerImportsCreate,
+ utils.VerbUpdate: accesscontrol.ActionAlertingAlertmanagerImportsWrite,
+ utils.VerbPatch: accesscontrol.ActionAlertingAlertmanagerImportsWrite,
+ utils.VerbDelete: accesscontrol.ActionAlertingAlertmanagerImportsDelete,
+ utils.VerbDeleteCollection: accesscontrol.ActionAlertingAlertmanagerImportsDelete,
+ },
+ folderSupport: false,
+ skipScopeOnVerb: map[string]bool{utils.VerbCreate: true},
+ }
+}
+
func NewMapperRegistry() MapperRegistry {
skipScopeOnAllVerbs := map[string]bool{
utils.VerbCreate: true,
@@ -258,6 +327,10 @@ func NewMapperRegistry() MapperRegistry {
}
mapper := mapper(map[string]map[string]translation{
+ "notifications.alerting.grafana.app": {
+ "routingtrees": newRoutingTreeTranslation(),
+ "alertmanagerimports": newAlertmanagerImportsTranslation(),
+ },
"dashboard.grafana.app": {
"dashboards": newDashboardTranslation(),
"librarypanels": newResourceTranslation("library.panels", "uid", true, nil),
diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go
index 2011596b9de50..a2b0af741af82 100644
--- a/pkg/services/authz/rbac/service.go
+++ b/pkg/services/authz/rbac/service.go
@@ -869,18 +869,39 @@ func (s *Service) newFolderTreeGetter(ctx context.Context, ns types.NamespaceInf
}
}
+// checkPermission dispatches to one of two checks based on mapper presence:
+//
+// - mapper hit (legacy registrations like dashboards, folders, librarypanels,
+// serviceaccounts, alerting, …) → checkPermissionWithMapping preserves the
+// existing HasFolderSupport / SkipScope / GeneralFolderUID inheritance
+// behaviour.
+// - mapper miss → checkPermissionWithFolderAuthz enforces the "stack role AND
+// folder permission" model for K8s-native resources.
+//
+// The fork is intentionally explicit so the folder-authz behaviour is easy to
+// read and easy to retire once all resources move to the mapper.
func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, req *checkRequest, getTree folderTreeGetter) (bool, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.checkPermission", trace.WithAttributes(
attribute.Int("scope_count", len(scopeMap))))
defer span.End()
ctxLogger := s.logger.FromContext(ctx)
- t, ok := s.mapper.Get(req.Group, req.Resource, req.Subresource)
- if !ok {
- ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", req.Group, "resource", req.Resource, "subresource", req.Subresource)
- t = newK8sNativeMapping(req.Group, req.Resource, req.Subresource)
+ if t, ok := s.mapper.Get(req.Group, req.Resource, req.Subresource); ok {
+ ctxLogger.Debug("resource in mapper, using checkPermissionWithMapping", "group", req.Group, "resource", req.Resource, "subresource", req.Subresource)
+ return s.checkPermissionWithMapping(ctx, scopeMap, req, t, getTree)
}
+ ctxLogger.Debug("checkPermission: mapper miss, using checkPermissionWithFolderAuthz",
+ "group", req.Group, "resource", req.Resource, "subresource", req.Subresource,
+ "verb", req.Verb, "name", req.Name, "parent_folder", req.ParentFolder,
+ "scope_count", len(scopeMap))
+ return s.checkPermissionWithFolderAuthz(ctx, scopeMap, req, getTree)
+}
+
+// checkPermissionWithMapping runs the default check for resources that have a
+// translation registered in mapper.go. Behaviour is preserved verbatim from the
+// pre-fork checkPermission.
+func (s *Service) checkPermissionWithMapping(ctx context.Context, scopeMap map[string]bool, req *checkRequest, t Mapping, getTree folderTreeGetter) (bool, error) {
if req.Name == "" && req.Verb != utils.VerbCreate {
// For resources that require a wildcard scope, we can perform the check immediately
if t.Scope("") == "*" {
@@ -916,6 +937,116 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool,
return s.checkInheritedPermissions(ctx, scopeMap, req, getTree)
}
+// checkPermissionWithFolderAuthz implements the "stack role AND folder
+// permission" model for K8s-native (mapper-miss) resources.
+// Apiextensions configures storage to check folder-scoped objects carry a non-root folder
+// annotation (see apistore.StorageOptions.RequireFolder), so the check here
+// reduces to presence-driven logic — no HasFolderSupport gate, no
+// GeneralFolderUID default.
+//
+// Stack-role interpretation:
+//
+// Grafana RBAC's getScopeMap collapses any wildcard scope (`*`, `widgets:*`,
+// etc.) into scopeMap["*"]; permissions granted with an empty scope land in
+// scopeMap[""]. In the folder-authz model both signal the same thing — "the
+// user holds the stack role for this action" — and neither is allowed to
+// auto-allow when the request targets an object in a folder. The folder
+// branch is always consulted whenever req.ParentFolder is set.
+//
+// Service identities / true admins bypass this function entirely via the
+// authz client's identity-type guard, so removing the wildcard auto-allow
+// here only affects user identities that hold a resource-type wildcard
+// without a matching folder grant.
+func (s *Service) checkPermissionWithFolderAuthz(ctx context.Context, scopeMap map[string]bool, req *checkRequest, getTree folderTreeGetter) (bool, error) {
+ ctxLogger := s.logger.FromContext(ctx).New("namespace", req.Namespace.Value, "group", req.Group, "resource", req.Resource, "verb", req.Verb, "name", req.Name, "parent_folder", req.ParentFolder)
+
+ hasStackRole := scopeMap[""]
+
+ // No stack role at all (can't proceed regardless of folder grants).
+ if !hasStackRole {
+ return false, nil
+ }
+
+ // Capabilities check: no specific object named and no folder context, so the
+ // caller is only asking whether the user could ever perform this action. The
+ // stack role alone answers that.
+ if req.ParentFolder == "" && req.Name == "" {
+ ctxLogger.Debug("folderAuthz: no parent folder provided, capabilities check")
+ return true, nil
+ }
+
+ // A specific object was named but its parent folder is unknown. Without a
+ // folder the only thing that can authorize the request is a wildcard folder
+ // grant (handled above).
+ if req.ParentFolder == "" {
+ return false, fmt.Errorf("k8s authorizer supports folder level not resource level authorization")
+ }
+
+ // The stack-role grant lives under the resource-type action
+ // (e.g. customcrdtest.ext.grafana.app/widgets:create), so the scopeMap
+ // passed in here only ever contains widget scopes. To enforce the folder
+ // half of the AND we have to issue a second permission query for the
+ // corresponding folder action (folders:read for reads, folders:write for
+ // writes, folders.permissions:write for permission verbs), include the
+ // action-set names so users granted via managed roles like "folders:edit"
+ // are matched, and finally run inheritance against the resulting scopeMap.
+ folderAction, folderActionSets := dualCheckFolderAuthz(req.Verb)
+ folderScopeMap, err := s.getCachedIdentityPermissions(ctx, req.Namespace, req.IdentityType, req.UserUID, folderAction)
+ if err != nil {
+ folderScopeMap, err = s.getIdentityPermissions(ctx, req.Namespace, req.IdentityType, req.UserUID, folderAction, folderActionSets)
+ if err != nil {
+ return false, err
+ }
+ }
+
+ // Wildcard folder grant (Folder admin / `folders:write` scope=*) → allow
+ // without walking the tree.
+ if folderScopeMap["*"] {
+ return true, nil
+ }
+
+ // Global access check failed, return early
+ if req.ParentFolder == "*" {
+ return false, nil
+ }
+
+ ctxLogger.Debug("folderAuthz: walking folder inheritance",
+ "folder_action", folderAction,
+ "folder_action_sets", folderActionSets,
+ "folder_scope_count", len(folderScopeMap))
+ allowed, err := s.checkInheritedPermissions(ctx, folderScopeMap, req, getTree)
+ return allowed, err
+}
+
+// dualCheckFolderAuthz returns the legacy folder action plus the action-set
+// names that gate folder-scoped CRD access in the dual-check model.
+//
+// - Read verbs (get/list/watch) require folders:read on the parent, or
+// equivalently any of the folders:view / folders:edit / folders:admin
+// action sets.
+// - Write verbs (create/update/patch/delete/deletecollection) require
+// folders:write on the parent, or equivalently folders:edit / folders:admin.
+// - Permission verbs (get/set permissions) require folder admin, so they map
+// to folders.permissions:write, or equivalently the folders:admin action set.
+//
+// Including the action-set names mirrors how the legacy folder mapper in
+// newFolderTranslation() resolves permissions, so users granted via managed
+// roles (which write the action-set name into the permissions table instead
+// of every individual action) are correctly matched.
+//
+// Unknown verbs fall through to the permission-management set so that anything
+// not explicitly classified still has to clear the folder-admin bar.
+func dualCheckFolderAuthz(verb string) (string, []string) {
+ switch verb {
+ case utils.VerbGet, utils.VerbList, utils.VerbWatch:
+ return "folders:read", []string{"folders:view", "folders:edit", "folders:admin"}
+ case utils.VerbCreate, utils.VerbUpdate, utils.VerbPatch, utils.VerbDelete, utils.VerbDeleteCollection:
+ return "folders:write", []string{"folders:edit", "folders:admin"}
+ default:
+ return "folders.permissions:write", []string{"folders:admin"}
+ }
+}
+
func (s *Service) getScopeMap(permissions []accesscontrol.Permission) map[string]bool {
permMap := make(map[string]bool, len(permissions))
for _, perm := range permissions {
diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go
index d8c2117f09eea..02c90bfa02795 100644
--- a/pkg/services/authz/rbac/service_test.go
+++ b/pkg/services/authz/rbac/service_test.go
@@ -1533,7 +1533,26 @@ func TestService_K8sNativeFallback(t *testing.T) {
assert.False(t, resp.Allowed)
})
- t.Run("Check: unregistered group allowed with K8s-native action", func(t *testing.T) {
+ t.Run("Check: unregistered group denied with stack-role grant and no folder (wildcard access)", func(t *testing.T) {
+ s := setup([]accesscontrol.Permission{
+ {Action: "unregistered.grafana.app/widgets:get", Scope: ""},
+ {Action: "folders:read", Scope: "folders:*"},
+ })
+ resp, err := s.Check(ctx, &authzv1.CheckRequest{
+ Namespace: "org-12",
+ Subject: "user:test-uid",
+ Group: "unregistered.grafana.app",
+ Resource: "widgets",
+ Verb: "get",
+ Name: "w1",
+ })
+ require.Error(t, err)
+ as
... [truncated]