Authorization / Access Control: Privilege escalation via RBAC misconfiguration (AllowsKind gating)

MEDIUM
grafana/grafana
Commit: 8246bd0effb1
Affected: Grafana 12.x before this commit; exact range not specified in patch. Likely 12.3.x and older.
2026-04-17 12:31 UTC

Description

The commit adds an AllowsKind gate and configurable ScopeAttribute for ResourcePermission mappers, plus a storage backend exclusion of BasicRole for service accounts. Before this change, nil allowedKinds meant all permission kinds (including BasicRole) could be assigned to resources, enabling potential authorization bypass/privilege escalation by granting BasicRole to a ServiceAccount or other resource. The patch hardens RBAC by ensuring only whitelisted kinds are allowed for a given resource and scope, and by avoiding dangerous combinations in storage.

Proof of Concept

Proof-of-concept (pre-fix behavior): 1) Prereqs: admin access to IAM permission API and a target service account with id 123. 2) Attempt to grant BasicRole to service account 123 on the serviceaccounts resource via ResourcePermission: POST /api/iam/resourcepermissions Content-Type: application/json Authorization: Bearer <token> { "resource": "serviceaccounts", "scope": "serviceaccounts:id:123", "permissions": [ { "kind": "BasicRole", "level": "Edit" } ] } Expected before fix: API accepts the request and creates the permission, effectively granting BasicRole to the SA. After fix (current patch): The mapper for serviceaccounts now restricts allowed kinds to User, ServiceAccount, and Team (BasicRole is not allowed). The API should reject the request with a validation error (e.g., 400/403). Attack surface: Privilege escalation on service accounts; cross-resource access depending on BasicRole capabilities.

Commit Details

Author: linoman

Date: 2026-04-17 12:02 UTC

Message:

AccessControl: add configurable ScopeAttribute and AllowsKind to ResourcePermission mapper (#122933)

Triage Assessment

Vulnerability Type: Access control / Authorization

Confidence: MEDIUM

Reasoning:

The change introduces configurable scope attributes and a permission-kind filter (AllowsKind) for resource permissions. This enables finer-grained RBAC controls and potential restriction of who can be granted permissions, addressing authorization bypass risks and unintended privilege grants. The storage backend also excludes certain combinations to prevent misuse (e.g., built-in BasicRole on service accounts). Overall, this strengthens access control semantics rather than purely adding a feature, which constitutes a security improvement.

Verification Assessment

Vulnerability Type: Authorization / Access Control: Privilege escalation via RBAC misconfiguration (AllowsKind gating)

Confidence: MEDIUM

Affected Versions: Grafana 12.x before this commit; exact range not specified in patch. Likely 12.3.x and older.

Code Diff

diff --git a/pkg/registry/apis/iam/resourcepermission/mapper.go b/pkg/registry/apis/iam/resourcepermission/mapper.go index e4faf2300b786..a1d327ae9682e 100644 --- a/pkg/registry/apis/iam/resourcepermission/mapper.go +++ b/pkg/registry/apis/iam/resourcepermission/mapper.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/grafana/authlib/types" + v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -33,21 +34,54 @@ type Mapper interface { // Used in list queries with SQL LIKE to match all permissions for this resource type. // Example: "folders:uid:%" matches "folders:uid:abc", "folders:uid:xyz", etc. ScopePattern() string + + // AllowsKind reports whether the resource type permits assignments to the given permission kind. + // Returns true when no kind restriction is configured (all kinds allowed). + AllowsKind(kind v0alpha1.ResourcePermissionSpecPermissionKind) bool } +// ScopeAttribute defines how a resource is identified in RBAC scope strings. +// It is the middle segment of a scope, e.g. "uid" in "folders:uid:abc". +type ScopeAttribute string + +const ( + // ScopeAttributeUID identifies resources by their UID (e.g. "folders:uid:abc"). + ScopeAttributeUID ScopeAttribute = "uid" + // ScopeAttributeID identifies resources by their numeric database ID (e.g. "serviceaccounts:id:123"). + ScopeAttributeID ScopeAttribute = "id" +) + type mapper struct { - resource string - actionSets []string + resource string + scopeAttribute ScopeAttribute + actionSets []string + allowedKinds []v0alpha1.ResourcePermissionSpecPermissionKind // nil = all kinds allowed } +// NewMapper creates a Mapper for uid-scoped resources (folders, dashboards). +// All permission kinds are allowed. func NewMapper(resource string, levels []string) Mapper { + return NewMapperWithAttribute(resource, levels, ScopeAttributeUID, nil) +} + +// NewIDScopedMapper creates a Mapper for id-scoped resources (teams, users). +// All permission kinds are allowed. +func NewIDScopedMapper(resource string, levels []string) Mapper { + return NewMapperWithAttribute(resource, levels, ScopeAttributeID, nil) +} + +// NewMapperWithAttribute creates a Mapper with an explicit scope attribute and optional kind restrictions. +// When allowedKinds is nil, all permission kinds are permitted. +func NewMapperWithAttribute(resource string, levels []string, attr ScopeAttribute, allowedKinds []v0alpha1.ResourcePermissionSpecPermissionKind) Mapper { sets := make([]string, 0, len(levels)) for _, level := range levels { sets = append(sets, resource+":"+strings.ToLower(level)) } return mapper{ - resource: resource, - actionSets: sets, + resource: resource, + scopeAttribute: attr, + actionSets: sets, + allowedKinds: allowedKinds, } } @@ -56,7 +90,7 @@ func (m mapper) ActionSets() []string { } func (m mapper) Scope(name string) string { - return m.resource + ":uid:" + name + return m.resource + ":" + string(m.scopeAttribute) + ":" + name } func (m mapper) ActionSet(level string) (string, error) { @@ -68,7 +102,14 @@ func (m mapper) ActionSet(level string) (string, error) { } func (m mapper) ScopePattern() string { - return m.resource + ":uid:%" + return m.resource + ":" + string(m.scopeAttribute) + ":%" +} + +func (m mapper) AllowsKind(kind v0alpha1.ResourcePermissionSpecPermissionKind) bool { + if m.allowedKinds == nil { + return true + } + return slices.Contains(m.allowedKinds, kind) } type mapperEntry struct { @@ -231,44 +272,6 @@ func (m *MappersRegistry) EnabledScopePatterns() []string { return out } -// NewIDScopedMapper creates a Mapper for resources stored with id-based scopes -// in the legacy permission table (teams, users, service accounts). -// Unlike the default uid-scoped mapper, its ScopePattern returns ":id:%". -// The actual uid↔id resolution is handled by scope_resolver functions via the -// MappersRegistry's identity store, not by the mapper itself. -func NewIDScopedMapper(resource string, levels []string) Mapper { - sets := make([]string, 0, len(levels)) - for _, level := range levels { - sets = append(sets, resource+":"+strings.ToLower(level)) - } - return idMapper{resource: resource, actionSets: sets} -} - -// idMapper implements Mapper for id-scoped resources. It differs from the default -// mapper only in ScopePattern (returns ":id:%" for DB queries). -type idMapper struct { - resource string - actionSets []string -} - -func (m idMapper) ActionSets() []string { return m.actionSets } - -func (m idMapper) ActionSet(level string) (string, error) { - actionSet := m.resource + ":" + strings.ToLower(level) - if !slices.Contains(m.actionSets, actionSet) { - return "", fmt.Errorf("invalid level (%s): %w", level, errInvalidSpec) - } - return actionSet, nil -} - -func (m idMapper) Scope(name string) string { - return m.resource + ":uid:" + name -} - -func (m idMapper) ScopePattern() string { - return m.resource + ":id:%" -} - // ParseScopeCtx parses an RBAC scope string into a groupResourceName, resolving id to uid for // id-scoped resources (teams, users, service accounts) using the provided store and namespace. // For uid-scoped resources (folders, dashboards) it behaves identically to ParseScope. diff --git a/pkg/registry/apis/iam/resourcepermission/mapper_test.go b/pkg/registry/apis/iam/resourcepermission/mapper_test.go index 5bae23a48682e..5db0631e069e8 100644 --- a/pkg/registry/apis/iam/resourcepermission/mapper_test.go +++ b/pkg/registry/apis/iam/resourcepermission/mapper_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" + + v0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" ) // --- NewMappersRegistry defaults --- @@ -362,3 +364,56 @@ func TestMappersRegistry_MultipleWildcards(t *testing.T) { require.NoError(t, err) assert.Equal(t, "unknown.alerting.grafana.app", alertGrn.Group) } + +// --- NewMapper / NewIDScopedMapper / NewMapperWithAttribute --- + +func TestNewMapper_UIDScoped(t *testing.T) { + m := NewMapper("folders", []string{"view", "edit", "admin"}) + + assert.Equal(t, "folders:uid:abc", m.Scope("abc")) + assert.Equal(t, "folders:uid:%", m.ScopePattern()) + assert.Equal(t, []string{"folders:view", "folders:edit", "folders:admin"}, m.ActionSets()) +} + +func TestNewIDScopedMapper_IDScoped(t *testing.T) { + m := NewIDScopedMapper("serviceaccounts", []string{"edit", "admin"}) + + assert.Equal(t, "serviceaccounts:id:123", m.Scope("123")) + assert.Equal(t, "serviceaccounts:id:%", m.ScopePattern()) +} + +func TestNewMapperWithAttribute_ExplicitAttribute(t *testing.T) { + uid := NewMapperWithAttribute("folders", []string{"view"}, ScopeAttributeUID, nil) + assert.Equal(t, "folders:uid:abc", uid.Scope("abc")) + assert.Equal(t, "folders:uid:%", uid.ScopePattern()) + + id := NewMapperWithAttribute("serviceaccounts", []string{"edit"}, ScopeAttributeID, nil) + assert.Equal(t, "serviceaccounts:id:123", id.Scope("123")) + assert.Equal(t, "serviceaccounts:id:%", id.ScopePattern()) +} + +// --- AllowsKind --- + +func TestMapper_AllowsKind_NilAllowedKinds(t *testing.T) { + m := NewMapper("folders", defaultLevels) + + // nil allowedKinds means all kinds are permitted + assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindUser)) + assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindTeam)) + assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount)) + assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindBasicRole)) +} + +func TestMapper_AllowsKind_RestrictedList(t *testing.T) { + allowedKinds := []v0alpha1.ResourcePermissionSpecPermissionKind{ + v0alpha1.ResourcePermissionSpecPermissionKindUser, + v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount, + v0alpha1.ResourcePermissionSpecPermissionKindTeam, + } + m := NewMapperWithAttribute("serviceaccounts", []string{"edit", "admin"}, ScopeAttributeID, allowedKinds) + + assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindUser)) + assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount)) + assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindTeam)) + assert.False(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindBasicRole)) +} diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend.go b/pkg/registry/apis/iam/resourcepermission/storage_backend.go index c06666eaa093e..6079a2ad7309b 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend.go @@ -49,9 +49,16 @@ func ProvideStorageBackend(dbProvider legacysql.LegacyDatabaseProvider, mappers schema.GroupResource{Group: "iam.grafana.app", Resource: "users"}, NewIDScopedMapper("users", defaultLevels), nil, ) + // BasicRole is excluded: built-in roles already cover all service accounts globally, + // so granting a ResourcePermission to a BasicRole on a specific SA is not permitted. mappers.RegisterMapper( schema.GroupResource{Group: "iam.grafana.app", Resource: "serviceaccounts"}, - NewIDScopedMapper("serviceaccounts", []string{"Edit", "Admin"}), nil, + NewMapperWithAttribute("serviceaccounts", []string{"Edit", "Admin"}, ScopeAttributeID, + []v0alpha1.ResourcePermissionSpecPermissionKind{ + v0alpha1.ResourcePermissionSpecPermissionKindUser, + v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount, + v0alpha1.ResourcePermissionSpecPermissionKindTeam, + }), nil, ) return &ResourcePermSqlBackend{ dbProvider: dbProvider,
← Back to Alerts View on GitHub →