Authorization / Privilege Escalation
Description
This commit hardens AccessControl by introducing per-resource permission kind restrictions (AllowsKind) for ResourcePermission mappers and by explicitly constraining Service Accounts (SA) to not be granted BasicRole at the resource level. Previously, mappers could implicitly allow all permission kinds (nil allowedKinds) for resources like serviceaccounts, which could enable a per-resource BasicRole grant to an SA. The patch adds: (1) a configurable ScopeAttribute and Scope attribute constants to make scope handling explicit, (2) a shared mapper with an AllowsKind(...) method to restrict which permission kinds are allowed per resource, and (3) storage_backend.go wiring that, for serviceaccounts, uses an explicit allowedKinds list excluding BasicRole. This closes a potential authorization bypass where a user could grant a BasicRole to an SA on a specific resource, effectively escalating privileges at the resource level when BasicRole is globally derived for SA.
Proof of Concept
PoC (conceptual, in a test environment with IAM enabled and prior to this patch):
Prerequisites:
- Grafana instance with IAM RBAC enabled.
- An attacker/user who can create ResourcePermission mappings for a resource type (e.g., serviceaccounts).
- A target ServiceAccount SA-1 and a resource (e.g., a folder F) to which permissions can be scoped.
Before fix (v12.4.0 and earlier):
1) Create a ResourcePermission mapping for the serviceaccounts resource with scope F (folders:uid:<folder-id>) and assign a permission kind of BasicRole to SA-1 on that scoped resource.
2) The authorization check may allow SA-1 to hold BasicRole on F, effectively granting per-resource privileges that BasicRole should not be allowed for an SA (since SA has global built-in coverage).
Expected result: SA-1 gains BasicRole permissions on folder F at the resource level, enabling privilege escalation for that resource.
After fix (post-commit 8167da142594e3a5d7d7f568734572ae8c5739f0):
1) Attempting to create a ResourcePermission mapping for serviceaccounts with allowedKinds including BasicRole will be rejected because the mapper now enforces allowedKinds. In storage_backend.go, the SA mapping for serviceaccounts excludes BasicRole and only permits User/ServiceAccount/Team kinds.
2) The check for per-resource assignment will only allow permitted kinds, preventing per-resource BasicRole grant for SA.
Example payloads (conceptual):
- Create mapper for SA: { "resource": "serviceaccounts", "scope": "serviceaccounts:id:123", "levels": ["Edit","Admin"], "allowedKinds": ["User","ServiceAccount","Team"] }
- Create ResourcePermission granting: { "resource": "serviceaccounts", "scope": "serviceaccounts:id:123", "permissionKinds": ["BasicRole"] }
Expected outcome after fix: The API should reject the above payload since BasicRole is not in the allowedKinds for serviceaccounts, preventing the per-resource BasicRole grant to SA-1.
Commit Details
Author: linoman
Date: 2026-04-16 18:00 UTC
Message:
AccessControl: add configurable ScopeAttribute and AllowsKind to ResourcePermission mapper (#122684)
* AccessControl: unify mapper struct with configurable ScopeAttribute
Merge `mapper` and `idMapper` into a single configurable struct.
Add `ScopeAttribute` typed constant (`ScopeAttributeUID` / `ScopeAttributeID`)
so the scope attribute is explicit and compile-time safe instead of an
arbitrary string.
`NewMapper` is unchanged for existing callers (uid-scoped resources).
`NewIDScopedMapper` becomes a thin wrapper — this also fixes a latent
bug where `idMapper.Scope()` was returning `:uid:` instead of `:id:`.
`NewMapperWithAttribute` exposes the full constructor for callers that
need an explicit scope attribute and kind restrictions.
Closes sub-task: add attribute + NewMapperWithAttribute (issue #1997)
* AccessControl: add AllowsKind to Mapper interface
Resources can restrict which assignment kinds they permit. Service accounts,
for example, do not support BasicRole assignments. When allowedKinds is nil
(the default for all existing mappers), all kinds continue to be allowed.
Closes sub-task: AllowsKind interface + implementation (issue #1997)
* AccessControl: restrict SA ResourcePermission mapper to allowed kinds
Service accounts do not support BasicRole assignments — built-in roles
already cover all service accounts globally, so per-resource grants to
BasicRole would be semantically wrong and inconsistent with the legacy
path which enforces the same constraint.
Closes sub-task: wire up SA mapper with allowedKinds (issue #1997)
* AccessControl: add unit tests for ScopeAttribute and AllowsKind
Covers uid vs id scope attribute paths (Scope/ScopePattern), and
AllowsKind with nil allowedKinds (all permitted) and a restricted
list (BasicRole excluded for service accounts).
Closes sub-task: unit tests (issue #1997)
Triage Assessment
Vulnerability Type: Privilege Escalation / Authorization
Confidence: HIGH
Reasoning:
The patch adds per-resource kind restrictions (AllowsKind) to the ResourcePermission mapper and wires them for service accounts so that certain permission kinds (e.g., BasicRole) cannot be assigned to service accounts at the resource level. This tightens authorization rules and prevents an incorrect privilege grant specific to resources, addressing a potential privilege escalation / access control bypass risk.
Verification Assessment
Vulnerability Type: Authorization / Privilege Escalation
Confidence: HIGH
Affected Versions: 12.4.0 and earlier
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,