Authorization bypass / Privilege escalation via K8s-native RBAC fallback for unregistered resources

MEDIUM
grafana/grafana
Commit: 1969485cc714
Affected: Grafana 12.4.0 (and later builds that include this commit)
2026-04-03 21:13 UTC

Description

The commit adds a fallback to a Kubernetes-native RBAC mapping for resources that are not registered in Grafana's internal RBAC mapper. This changes the authorization flow so that unregistered groups/resources are evaluated against a K8s-native action set rather than returning NotFound. While this can improve flexibility and correctness for unregistered resources, it also opens the possibility of an authorization bypass: if a user or service account has explicit K8s-native permissions for an unregistered resource (e.g., action myapp/widgets:get under group unregistered.grafana.app), they may gain access through the fallback path even though Grafana’s own mapper would not recognize the resource. The patch includes tests that demonstrate the fallback behavior, including an explicit test showing access is allowed when a K8s-native action permission exists for an unregistered group/resource.

Proof of Concept

POC steps: 1) Ensure there is no Grafana mapper entry for group 'unregistered.grafana.app' resource 'widgets'. 2) Insert a permission record for the unregistered group: Action: 'unregistered.grafana.app/widgets:get', Scope: 'unregistered.grafana.app/widgets:uid:w1'. 3) Send an authorization check for the unregistered group with the K8s-native action: - Namespace: 'org-12' - Subject: 'user:test-uid' - Group: 'unregistered.grafana.app' - Resource: 'widgets' - Verb: 'get' - Name: 'w1' 4) Expected result: Allowed = true, demonstrating that K8s-native fallback permits access for an unregistered group/resource when a matching K8s-native permission exists. Go-like conceptual PoC (illustrative, not a runnable snippet here): // Assuming a service instance 'svc' and a request context 'ctx' resp, err := svc.Check(ctx, &authzv1.CheckRequest{Namespace:"org-12", Subject:"user:test-uid", Group:"unregistered.grafana.app", Resource:"widgets", Verb:"get", Name:"w1"}) if err == nil && resp.Allowed { // Access granted via K8s-native fallback }

Commit Details

Author: Gabriel MABILLE

Date: 2026-03-11 17:11 UTC

Message:

`AuthZService`: Fallback to k8s formatted RBAC permissions (#119840) * `AuthZService`: Fallback to k8s formatted RBAC permissions * lint

Triage Assessment

Vulnerability Type: Privilege escalation / Authorization bypass (access control)

Confidence: MEDIUM

Reasoning:

The commit changes the authorization flow to fall back to a Kubernetes-native RBAC mapping for resources not present in the existing mapper, instead of returning NotFound. This affects how access checks are performed for unregistered groups/resources and includes related tests. By enabling fallback evaluation and adjusting logging, it tightens or clarifies permission decisions for unregistered resources, which has security implications for access control behavior.

Verification Assessment

Vulnerability Type: Authorization bypass / Privilege escalation via K8s-native RBAC fallback for unregistered resources

Confidence: MEDIUM

Affected Versions: Grafana 12.4.0 (and later builds that include this commit)

Code Diff

diff --git a/pkg/services/authz/rbac/k8s_native_mapping.go b/pkg/services/authz/rbac/k8s_native_mapping.go new file mode 100644 index 0000000000000..08debeab3ce9f --- /dev/null +++ b/pkg/services/authz/rbac/k8s_native_mapping.go @@ -0,0 +1,97 @@ +package rbac + +import "github.com/grafana/grafana/pkg/apimachinery/utils" + +// k8sVerbMap maps K8s verbs to the canonical set of RBAC verbs used in K8s-native +// action strings. Multiple K8s verbs collapse to a single RBAC verb: +// +// list, watch → get +// patch → update +// deletecollection → delete +var k8sVerbMap = map[string]string{ + utils.VerbGet: "get", + utils.VerbList: "get", + utils.VerbWatch: "get", + utils.VerbCreate: "create", + utils.VerbUpdate: "update", + utils.VerbPatch: "update", + utils.VerbDelete: "delete", + utils.VerbDeleteCollection: "delete", + utils.VerbGetPermissions: "get_permissions", + utils.VerbSetPermissions: "set_permissions", +} + +// k8sNativeMapping is a deterministic Mapping for resources not registered in the +// mapper. Actions follow the {group}/{resource}:{verb} format and all values are +// derived at call time from the group and resource name alone. +type k8sNativeMapping struct { + group string + resource string +} + +func newK8sNativeMapping(group, resource string) Mapping { + return &k8sNativeMapping{group: group, resource: resource} +} + +// Action returns the RBAC action for the given K8s verb in the format +// {group}/{resource}:{rbacVerb}. +func (m *k8sNativeMapping) Action(verb string) (string, bool) { + v, ok := k8sVerbMap[verb] + if !ok { + return "", false + } + return m.group + "/" + m.resource + ":" + v, true +} + +// ActionSets returns nil; K8s-native resources have no legacy RBAC action sets. +// Can be challenged if we want to support (folders:admin, folders:edit, folders:view) action sets. +func (m *k8sNativeMapping) ActionSets(_ string) []string { + return nil +} + +// Scope returns the RBAC scope for the given resource instance name. +// Format: {resource}:uid:{name} +func (m *k8sNativeMapping) Scope(name string) string { + return m.group + "/" + m.resource + ":uid:" + name +} + +// Prefix returns the scope prefix used for list queries. +// Format: {resource}:uid: +func (m *k8sNativeMapping) Prefix() string { + return m.group + "/" + m.resource + ":uid:" +} + +// AllActions returns the deduplicated set of RBAC actions for this resource. +func (m *k8sNativeMapping) AllActions() []string { + base := m.group + "/" + m.resource + ":" + seen := make(map[string]struct{}, len(k8sVerbMap)) + actions := make([]string, 0, len(k8sVerbMap)) + for _, v := range k8sVerbMap { + action := base + v + if _, ok := seen[action]; ok { + continue + } + seen[action] = struct{}{} + actions = append(actions, action) + } + return actions +} + +// HasFolderSupport always returns true for K8s-native resources. +// +// Defaulting to true is the safe choice: if a resource does not live in a +// folder, the permission would not have been granted on any parent folder +// in the first place. +func (m *k8sNativeMapping) HasFolderSupport() bool { + return true +} + +// SkipScope always returns false; no verb skips scope checks by default. +func (m *k8sNativeMapping) SkipScope(_ string) bool { + return false +} + +// Resource returns the K8s resource name (without the group prefix). +func (m *k8sNativeMapping) Resource() string { + return m.resource +} diff --git a/pkg/services/authz/rbac/k8s_native_mapping_test.go b/pkg/services/authz/rbac/k8s_native_mapping_test.go new file mode 100644 index 0000000000000..267f7d88854c5 --- /dev/null +++ b/pkg/services/authz/rbac/k8s_native_mapping_test.go @@ -0,0 +1,117 @@ +package rbac + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func TestK8sNativeMapping_Action(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + + tests := []struct { + verb string + expectedAction string + expectOk bool + }{ + // Direct verbs + {utils.VerbGet, "myapp.ext.grafana.app/widgets:get", true}, + {utils.VerbCreate, "myapp.ext.grafana.app/widgets:create", true}, + {utils.VerbUpdate, "myapp.ext.grafana.app/widgets:update", true}, + {utils.VerbDelete, "myapp.ext.grafana.app/widgets:delete", true}, + {utils.VerbGetPermissions, "myapp.ext.grafana.app/widgets:get_permissions", true}, + {utils.VerbSetPermissions, "myapp.ext.grafana.app/widgets:set_permissions", true}, + // Collapsed verbs + {utils.VerbList, "myapp.ext.grafana.app/widgets:get", true}, + {utils.VerbWatch, "myapp.ext.grafana.app/widgets:get", true}, + {utils.VerbPatch, "myapp.ext.grafana.app/widgets:update", true}, + {utils.VerbDeleteCollection, "myapp.ext.grafana.app/widgets:delete", true}, + // Unknown verb + {"unknownverb", "", false}, + {"", "", false}, + } + + for _, tt := range tests { + t.Run(tt.verb, func(t *testing.T) { + action, ok := m.Action(tt.verb) + assert.Equal(t, tt.expectOk, ok) + assert.Equal(t, tt.expectedAction, action) + }) + } +} + +func TestK8sNativeMapping_ActionSets(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + // K8s-native resources have no legacy RBAC action sets. + for _, verb := range []string{utils.VerbGet, utils.VerbList, utils.VerbCreate, utils.VerbUpdate, utils.VerbDelete} { + assert.Nil(t, m.ActionSets(verb), "ActionSets should be nil for verb %q", verb) + } +} + +func TestK8sNativeMapping_Scope(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + + assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:abc123", m.Scope("abc123")) + assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:some-uid", m.Scope("some-uid")) + assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:", m.Scope("")) +} + +func TestK8sNativeMapping_Prefix(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + assert.Equal(t, "myapp.ext.grafana.app/widgets:uid:", m.Prefix()) +} + +func TestK8sNativeMapping_HasFolderSupport(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + assert.True(t, m.HasFolderSupport(), + "K8s-native mappings always report folder support so that folder-scoped inheritance works correctly") +} + +func TestK8sNativeMapping_SkipScope(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + for _, verb := range []string{utils.VerbGet, utils.VerbCreate, utils.VerbUpdate, utils.VerbDelete, utils.VerbGetPermissions, utils.VerbSetPermissions} { + assert.False(t, m.SkipScope(verb), "SkipScope should be false for verb %q", verb) + } +} + +func TestK8sNativeMapping_Resource(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + assert.Equal(t, "widgets", m.Resource(), "Resource() should return the K8s resource name without the group prefix") +} + +func TestK8sNativeMapping_AllActions(t *testing.T) { + m := newK8sNativeMapping("myapp.ext.grafana.app", "widgets") + actions := m.AllActions() + + // Must be deduplicated (list/watch collapse to get, patch to update, deletecollection to delete) + expected := []string{ + "myapp.ext.grafana.app/widgets:create", + "myapp.ext.grafana.app/widgets:delete", + "myapp.ext.grafana.app/widgets:get", + "myapp.ext.grafana.app/widgets:get_permissions", + "myapp.ext.grafana.app/widgets:set_permissions", + "myapp.ext.grafana.app/widgets:update", + } + + sort.Strings(actions) + assert.Equal(t, expected, actions) +} + +func TestK8sNativeMapping_DifferentGroups(t *testing.T) { + // Two apps with the same resource name must produce distinct actions. + m1 := newK8sNativeMapping("app1.ext.grafana.app", "widgets") + m2 := newK8sNativeMapping("app2.ext.grafana.app", "widgets") + + action1, ok1 := m1.Action(utils.VerbGet) + action2, ok2 := m2.Action(utils.VerbGet) + + require.True(t, ok1) + require.True(t, ok2) + assert.NotEqual(t, action1, action2, "different groups must produce distinct actions for the same resource") + assert.Equal(t, "app1.ext.grafana.app/widgets:get", action1) + assert.Equal(t, "app2.ext.grafana.app/widgets:get", action2) +} diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 91eef34f937b6..463ed32f29638 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -587,8 +587,8 @@ func (s *Service) validateAction(ctx context.Context, group, resource, verb stri t, ok := s.mapper.Get(group, resource) if !ok { - ctxLogger.Error("unsupported resource", "group", group, "resource", resource) - return "", nil, status.Error(codes.NotFound, "unsupported resource") + ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", group, "resource", resource) + t = newK8sNativeMapping(group, resource) } action, ok := t.Action(verb) @@ -858,8 +858,8 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool, t, ok := s.mapper.Get(req.Group, req.Resource) if !ok { - ctxLogger.Error("unsupport resource", "group", req.Group, "resource", req.Resource) - return false, status.Error(codes.NotFound, "unsupported resource") + ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", req.Group, "resource", req.Resource) + t = newK8sNativeMapping(req.Group, req.Resource) } if req.Name == "" && req.Verb != utils.VerbCreate { @@ -1019,8 +1019,8 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, t, ok := s.mapper.Get(req.Group, req.Resource) if !ok { - ctxLogger.Error("unsupported resource", "group", req.Group, "resource", req.Resource) - return nil, status.Error(codes.NotFound, "unsupported resource") + ctxLogger.Debug("resource not in mapper, using K8s-native fallback", "group", req.Group, "resource", req.Resource) + t = newK8sNativeMapping(req.Group, req.Resource) } var tree folderTree diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go index a12aefc20df75..213902aa82d18 100644 --- a/pkg/services/authz/rbac/service_test.go +++ b/pkg/services/authz/rbac/service_test.go @@ -1006,18 +1006,6 @@ func TestService_Check(t *testing.T) { }, expectErr: true, }, - { - name: "should error if an unknown group is provided", - req: &authzv1.CheckRequest{ - Namespace: "org-12", - Subject: "user:test-uid", - Group: "unknown.grafana.app", - Resource: "unknown", - Verb: "get", - Name: "u1", - }, - expectErr: true, - }, { name: "should error if an unknown verb is provided", req: &authzv1.CheckRequest{ @@ -1264,7 +1252,9 @@ func TestService_Check(t *testing.T) { expected: true, }, { - name: "should deny rendering access to another app resources", + // Unregistered groups fall back to the K8s-native mapping. The renderer has no + // permissions for K8s-native actions, so the check is denied without an error. + name: "should deny rendering access to unregistered app resources", req: &authzv1.CheckRequest{ Namespace: "org-12", Subject: "render:0", @@ -1273,8 +1263,7 @@ func TestService_Check(t *testing.T) { Verb: "get", Name: "dash1", }, - expected: false, - expectErr: true, + expected: false, }, } t.Run("Rendering permission check", func(t *testing.T) { @@ -1295,6 +1284,102 @@ func TestService_Check(t *testing.T) { }) } +func TestService_K8sNativeFallback(t *testing.T) { + callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ + Claims: jwt.Claims{ + Subject: types.NewTypeID(types.TypeAccessPolicy, "some-service"), + Audience: []string{"authzservice"}, + }, + Rest: authn.AccessTokenClaims{Namespace: "org-12"}, + }) + + setup := func(permissions []accesscontrol.Permission) *Service { + s := setupService() + userID := &store.UserIdentifiers{UID: "test-uid", ID: 1} + fStore := &fakeStore{userID: userID, userPermissions: permissions} + s.store = fStore + s.permissionStore = fStore + s.identityStore = &fakeIdentityStore{} + return s + } + + ctx := types.WithAuthInfo(context.Background(), callingService) + + t.Run("Check: unregistered group denied when no permissions", func(t *testing.T) { + s := setup([]accesscontrol.Permission{}) + 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.NoError(t, err) + assert.False(t, resp.Allowed) + }) + + t.Run("Check: unregistered group allowed with K8s-native action", func(t *testing.T) { + s := setup([]accesscontrol.Permission{ + {Action: "unregistered.grafana.app/widgets:get", Scope: "unregistered.grafana.app/widgets:uid:w1"}, + }) + 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.NoError(t, err) + assert.True(t, resp.Allowed) + }) + + t.Run("Check: unknown verb still errors", func(t *testing.T) { + s := setup([]accesscontrol.Permission{}) + _, err := s.Check(ctx, &authzv1.CheckRequest{ + Namespace: "org-12", + Subject: "user:test-uid", + Group: "unregistered.grafana.app", + Resource: "widgets", + Verb: "unknownverb", + Name: "w1", + }) + require.Error(t, err) + }) + + t.Run("List: unregistered group returns empty without error", func(t *testing.T) { + s := setup([]accesscontrol.Permission{}) + resp, err := s.List(ctx, &authzv1.ListRequest{ + Namespace: "org-12", + Subject: "user:test-uid", + Group: "unregistered.grafana.app", + Resource: "widgets", + Verb: "get", + }) + require.NoError(t, err) + assert.Empty(t, resp.Items) + assert.Empty(t, resp.Folders) + assert.False(t, resp.All) + }) + + t.Run("List: unregistered group returns items with K8s-native action", func(t *testing.T) { + 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"}, + }) + resp, err := s.List(ctx, &authzv1.ListRequest{ + Namespace: "org-12", + Subject: "user:test-uid", + Group: "unregistered.grafana.app", + Resource: "widgets", + Verb: "get", + }) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"w1", "w2"}, resp.Items) + }) +} + func TestService_CacheCheck(t *testing.T) { callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ Claims: jwt.Claims{ @@ -1487,17 +1572,6 @@ func TestService_List(t *testing.T) { }, expectErr: true, }, - { - name: "should ... [truncated]
← Back to Alerts View on GitHub →