Privilege Escalation / Access Control Bypass in RBAC (unmapped resources folder-scoped).

HIGH
grafana/grafana
Commit: b9b897b3c512
Affected: <= 12.3.x (pre-12.4.0)
2026-06-23 10:35 UTC

Description

The commit adds folder-scoped authorization checks for resources that are not present in the RBAC mapper (mapper miss). Prior to this patch, wildcard resource grants (scope: "*") could be used to bypass folder-scoped access control on unmapped resources, enabling privilege escalation. The fix introduces a dedicated path listPermissionWithFolderAuthz to enforce that access is granted only when both a resource-level stack role (scope: "") exists and the user has appropriate folder grants. It prevents wildcard grants from automatically authorizing access to folder-scoped resources that are not mapped in the RBAC mapper.

Proof of Concept

PoC (actionable reproduction of vulnerability):\n\nPrereqs:\n- Grafana RBAC with unmapped resource type (no mapper entry) for a folder-scoped CRD (e.g., widgets under a group) that is not mapped in the RBAC mapper.\n- A user with the following two permissions:\n 1) Resource stack role on the unmapped resource: Action: <group>/widgets:get, Scope: \"\" (empty)\n 2) Folder grant that would allow reading folders (e.g., folders:read) with Scope: \"folders:uid:*\" (or a wildcard on folders.read)\n\nAttack steps (before fix would be in scope):\n1) Act as the user and request a List on the unmapped resource (group: <group>, resource: widgets, verb: list).\n2) The service resolves the mapper for (group, widgets). Since the resource is unmapped, it should take the folder-authz path.\n3) If the implementation incorrectly treats a resource wildcard (Scope: \"*\") on the unmapped resource as granting access without considering folder grants, the call would return All (read access to all folders and descendants).\n\nExpected (pre-fix): The attacker would receive All: true (and potentially access all items/folders) based solely on a wildcard resource grant combined with folder grants.\n\nExpected (post-fix): The code path enforces the two-step gating: The request is accepted only if the user has a valid resource stack role (scope: \"\") AND explicit folder grants; A resource wildcard grant alone does not bypass folder checks for unmapped resources, preventing privilege escalation.\n\nEvidence snippet (conceptual API call):\ncurl -s -H \"Authorization: Bearer <token>\" -X POST https://grafana.example/api/authorization/list -d '{\n \"Namespace\": {\"Value\":\"default\"},\n \"IdentityType\": \"TypeUser\",\n \"UserUID\": \"user-uid\",\n \"Group\": \"widget-ext.grafana.app\",\n \"Resource\": \"widgets\",\n \"Verb\": \"list\",\n \"Action\": \"widget-ext.grafana.app/widgets:get\",\n \"Options\": {}\n}'\n\nExpected response (vulnerable, pre-fix): {\"All\": true, \"Folders\": [\"parent\",\"child\"], \"Items\": []}\nExpected response (fixed): {\"All\": false, \"Folders\": [], \"Items\": []}

Commit Details

Author: Costa Alexoglou

Date: 2026-06-23 09:44 UTC

Message:

IAM: folder-scoped authz with LIST (#126931) * feat: folder-scoped authz with LIST * chore: review comment

Triage Assessment

Vulnerability Type: Privilege Escalation / Access Control bypass

Confidence: HIGH

Reasoning:

The patch introduces folder-scoped authorization (authz) logic to ensure that resource-type wildcard grants do not auto-allow access and that access is gated by folder-level permissions. It adds a dedicated path (listPermissionWithFolderAuthz) to enforce a two-step check (stack role on resource + folder grants) and prevents unauthorized access to objects under folders. This directly mitigates potential privilege-escalation or unauthorized access via wildcard/resource-level grants, especially for folder-scoped resources.

Verification Assessment

Vulnerability Type: Privilege Escalation / Access Control Bypass in RBAC (unmapped resources folder-scoped).

Confidence: HIGH

Affected Versions: <= 12.3.x (pre-12.4.0)

Code Diff

diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index a2b0af741af82..e35a733b7a3d4 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -975,9 +975,6 @@ func (s *Service) checkPermissionWithFolderAuthz(ctx context.Context, scopeMap m 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") } @@ -1155,6 +1152,15 @@ func (s *Service) buildFolderTree(ctx context.Context, ns types.NamespaceInfo) ( } func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, req *listRequest) (*authzv1.ListResponse, error) { + // Mapper-miss resources (folder-scoped CRDs, *.ext.grafana.app) use the + // dual-check folder-authz model. Fork here before the scopeMap["*"] early + // return so a resource-type wildcard does not auto-allow all objects for + // folder-scoped CRDs (consistent with checkPermissionWithFolderAuthz). + t, mapperFound := s.mapper.Get(req.Group, req.Resource, req.Subresource) + if !mapperFound { + return s.listPermissionWithFolderAuthz(ctx, scopeMap, req) + } + if scopeMap["*"] { return &authzv1.ListResponse{ All: true, @@ -1166,12 +1172,6 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, 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) - } - var tree folderTree cacheHit := false if t.HasFolderSupport() { @@ -1205,6 +1205,85 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, return res, nil } +// listPermissionWithFolderAuthz implements the "stack role AND folder +// permission" model for the List/Compile path of K8s-native (mapper-miss) +// resources, mirroring checkPermissionWithFolderAuthz. +// +// The stack-role grant lives under the resource-type action with an empty +// scope (scopeMap[""]). A resource-type wildcard (scopeMap["*"]) is +// deliberately not treated as the stack role and must not auto-allow, which is +// why the caller forks here before listPermission's scopeMap["*"] early +// return. Once the stack role is established we issue a second query for the +// user's folder grants and return the set of readable folders, so the compiled +// ItemChecker allows any object whose parent folder is readable. +func (s *Service) listPermissionWithFolderAuthz(ctx context.Context, scopeMap map[string]bool, req *listRequest) (*authzv1.ListResponse, error) { + ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.listPermissionWithFolderAuthz") + defer span.End() + ctxLogger := s.logger.FromContext(ctx).New("namespace", req.Namespace.Value, "group", req.Group, "resource", req.Resource, "verb", req.Verb) + + // No stack role at all (can't proceed regardless of folder grants). + // Gate on scopeMap[""] only: a resource-type wildcard must not be treated + // as the stack role nor auto-allow. + if !scopeMap[""] { + ctxLogger.Debug("folderAuthz: no stack role, returning empty list response") + return &authzv1.ListResponse{Zookie: &authzv1.Zookie{Timestamp: time.Now().Unix()}}, nil + } + + // Issue a second permission query for the corresponding folder action + // (folders:read for reads), including the action-set names so users granted + // via managed roles like "folders:edit" are matched. + folderAction, folderActionSets := dualCheckFolderAuthz(req.Verb) + folderScopeMap, err := s.getCachedIdentityPermissions(ctx, req.Namespace, req.IdentityType, req.UserUID, folderAction) + permsFromCache := err == nil + if err != nil { + folderScopeMap, err = s.getIdentityPermissions(ctx, req.Namespace, req.IdentityType, req.UserUID, folderAction, folderActionSets) + if err != nil { + return nil, err + } + } + + // Wildcard folder grant (Folder admin / folders:read scope=*) → allow all. + if folderScopeMap["*"] { + timestamp := time.Now() + if permsFromCache { + timestamp = timestamp.Add(-s.settings.CacheTTL) + } + return &authzv1.ListResponse{ + All: true, + Zookie: &authzv1.Zookie{Timestamp: timestamp.Unix()}, + }, nil + } + + var tree folderTree + cacheHit := false + if !req.Options.SkipCache { + tree, cacheHit = s.getCachedFolderTree(ctx, req.Namespace) + } + if !cacheHit { + tree, err = s.buildFolderTree(ctx, req.Namespace) + if err != nil { + ctxLogger.Error("could not build folder tree", "error", err) + return nil, err + } + } + + // Feed the folder scopeMap to buildItemList: folder scopes (folders:uid:*) + // land in the Folders field (expanded to descendants), Items stays empty. + // The prefix is irrelevant here since the folder scopeMap has no resource + // scopes. Do not use buildFolderList — it puts folder UIDs in the Items + // field, which would deny every real object. + res := buildItemList(folderScopeMap, tree, "") + + if cacheHit { + res.Zookie = &authzv1.Zookie{Timestamp: time.Now().Add(-s.settings.CacheTTL).Unix()} + } else { + res.Zookie = &authzv1.Zookie{Timestamp: time.Now().Unix()} + } + + span.SetAttributes(attribute.Int("num_folders", len(res.Folders)), attribute.Int("num_items", len(res.Items))) + return res, nil +} + func buildFolderList(scopes map[string]bool, tree folderTree) *authzv1.ListResponse { itemSet := make(map[string]struct{}, len(scopes)) diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go index 02c90bfa02795..7723b7469d454 100644 --- a/pkg/services/authz/rbac/service_test.go +++ b/pkg/services/authz/rbac/service_test.go @@ -1077,6 +1077,120 @@ func TestService_listPermission(t *testing.T) { } } +func TestService_listPermissionWithFolderAuthz(t *testing.T) { + const group = "widget.ext.grafana.app" + + folderPerm := func(action, folderUID string) accesscontrol.Permission { + return accesscontrol.Permission{ + Action: action, + Scope: "folders:uid:" + folderUID, + Kind: "folders", + Attribute: "uid", + Identifier: folderUID, + } + } + + type testCase struct { + name string + // resourceScopeMap is the scope map for the resource action passed to + // listPermission (scopeMap[""] signals the stack role). + resourceScopeMap map[string]bool + // folderPerms are the user's folder grants, queried second. + folderPerms []accesscontrol.Permission + folders []store.Folder + verb string + expectedAll bool + expectedFolders []string + } + + testCases := []testCase{ + { + name: "no stack role returns empty response", + resourceScopeMap: map[string]bool{}, + folderPerms: []accesscontrol.Permission{folderPerm("folders:read", "f1")}, + folders: []store.Folder{{UID: "f1"}}, + verb: utils.VerbList, + }, + { + name: "resource-type wildcard does not auto-allow without folder grant", + resourceScopeMap: map[string]bool{"*": true}, + folderPerms: []accesscontrol.Permission{}, + folders: []store.Folder{{UID: "f1"}}, + verb: utils.VerbList, + }, + { + name: "stack role and folders:read on parent returns folder and descendants", + resourceScopeMap: map[string]bool{"": true}, + folderPerms: []accesscontrol.Permission{folderPerm("folders:read", "parent")}, + folders: []store.Folder{{UID: "parent"}, {UID: "child", ParentUID: new("parent")}}, + verb: utils.VerbList, + expectedFolders: []string{"parent", "child"}, + }, + { + name: "stack role and folder grant via action-set name is matched", + resourceScopeMap: map[string]bool{"": true}, + folderPerms: []accesscontrol.Permission{folderPerm("folders:edit", "f1")}, + folders: []store.Folder{{UID: "f1"}}, + verb: utils.VerbList, + expectedFolders: []string{"f1"}, + }, + { + name: "stack role and folder wildcard returns all", + resourceScopeMap: map[string]bool{"": true}, + folderPerms: []accesscontrol.Permission{{Action: "folders:read", Scope: "*", Kind: "*"}}, + folders: []store.Folder{{UID: "f1"}}, + verb: utils.VerbList, + expectedAll: true, + }, + { + name: "watch verb behaves like list", + resourceScopeMap: map[string]bool{"": true}, + folderPerms: []accesscontrol.Permission{folderPerm("folders:read", "f1")}, + folders: []store.Folder{{UID: "f1"}}, + verb: utils.VerbWatch, + expectedFolders: []string{"f1"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := setupService() + userID := &store.UserIdentifiers{UID: "test-uid", ID: 1} + fStore := &fakeStore{userID: userID, userPermissions: tc.folderPerms, folders: tc.folders, disableNsCheck: true} + s.store = fStore + s.permissionStore = fStore + s.folderStore = fStore + s.identityStore = &fakeIdentityStore{disableNsCheck: true} + + if tc.folders != nil { + s.folderCache.Set(context.Background(), folderCacheKey("default"), newFolderTree(tc.folders)) + } + + list := listRequest{ + Namespace: types.NamespaceInfo{Value: "default", OrgID: 1}, + IdentityType: types.TypeUser, + UserUID: "test-uid", + Group: group, + Resource: "widgets", + Verb: tc.verb, + Action: group + "/widgets:get", + Options: &ListRequestOptions{}, + } + + got, err := s.listPermission(context.Background(), tc.resourceScopeMap, &list) + require.NoError(t, err) + assert.Equal(t, tc.expectedAll, got.All) + assert.ElementsMatch(t, tc.expectedFolders, got.Folders) + // The folder-authz path expresses access only through Folders (or + // All), never Items: Items matches by object name, which is + // meaningless for folder-scoped CRDs. A regression that swapped + // buildItemList for buildFolderList would leak folder UIDs here and + // silently deny every object, so we assert it stays empty. + assert.Empty(t, got.Items) + }) + } +} + func TestService_Check(t *testing.T) { callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ Claims: jwt.Claims{ @@ -1596,7 +1710,9 @@ func TestService_K8sNativeFallback(t *testing.T) { assert.False(t, resp.All) }) - t.Run("List: unregistered group returns items with K8s-native action", func(t *testing.T) { + t.Run("List: unregistered group with resource-scoped grant but no stack role returns empty", func(t *testing.T) { + // A scoped grant on the resource is no longer a stack role, so without + // an empty-scope grant the folder-authz list path returns nothing. s := setup([]accesscontrol.Permission{ {Action: "unregistered.grafana.app/widgets:get", Scope: "unregistered.grafana.app/widgets:uid:w1"}, {Action: "unregistered.grafana.app/widgets:get", Scope: "unregistered.grafana.app/widgets:uid:w2"}, @@ -1606,10 +1722,32 @@ func TestService_K8sNativeFallback(t *testing.T) { Subject: "user:test-uid", Group: "unregistered.grafana.app", Resource: "widgets", - Verb: "get", + Verb: "list", }) require.NoError(t, err) - assert.ElementsMatch(t, []string{"w1", "w2"}, resp.Items) + assert.Empty(t, resp.Items) + assert.Empty(t, resp.Folders) + assert.False(t, resp.All) + }) + + t.Run("List: unregistered group with stack role and folder grant returns folders", func(t *testing.T) { + s := setup([]accesscontrol.Permission{ + {Action: "unregistered.grafana.app/widgets:get", Scope: ""}, + {Action: "folders:read", Scope: "folders:uid:f1", Kind: "folders", Attribute: "uid", Identifier: "f1"}, + }) + s.folderStore = s.store.(*fakeStore) + s.store.(*fakeStore).folders = []store.Folder{{UID: "f1"}} + resp, err := s.List(ctx, &authzv1.ListRequest{ + Namespace: "org-12", + Subject: "user:test-uid", + Group: "unregistered.grafana.app", + Resource: "widgets", + Verb: "list", + }) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"f1"}, resp.Folders) + assert.Empty(t, resp.Items) + assert.False(t, resp.All) }) t.Run("List: dashboards/annotations subresource resolves correct mapper entry", func(t *testing.T) {
← Back to Alerts View on GitHub →