Access Control / Authorization (RBAC) - Folder-scoped authorization for Kubernetes-native resources

HIGH
grafana/grafana
Commit: adeb8760a020
Affected: 12.4.0 and earlier
2026-06-18 12:38 UTC

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