Privilege escalation via incorrect identity scope resolution in IAM write path
Description
The commit fixes the resolution of identity scopes for id-scoped mappers (e.g., users, teams, service accounts) by introducing a UIDScope(name) method on the Mapper and using UIDScope(name) when translating scopes for write operations. Previously, resolveScope could pass an id-scoped form (serviceaccounts:id:<uid>) into ResolveUIDScopeForWrite, which is fragile and may mis-resolve the identity scope, potentially causing incorrect permission assignments. The patch ensures that id-scoped resources are consistently translated to the UID-based scope (serviceaccounts:uid:<uid>) for the identity store lookup, preventing mis-granting or mis-application of permissions. Additional tests for service accounts were added to validate the authorization behavior.
Proof of Concept
PoC (conceptual, actionable steps to reproduce the issue before/after the fix):
Prerequisites:
- Grafana IAM backend with id-scoped mappers enabled (serviceaccounts, users, teams).
- An identity store mapping from UID to a known service account UID (e.g., uid 3 -> sa-1).
- A role with a permission that uses an id-scoped scope referencing a UID (e.g., scope = serviceaccounts:id:3).
Steps (pre-fix behavior):
1) Seed a permission on role 1 for subject X with scope = "serviceaccounts:id:3" (id-based form).
2) Query the effective permissions for subject X (ListDirectPermissionsForSubject).
3) Observe that the permission may not be correctly translated to the UID form used by the runtime (serviceaccounts:uid:sa-1) or could be evaluated against an incorrect identity, leading to incorrect authorization decisions (either denied when it should be allowed, or granted inappropriately).
Steps (post-fix behavior using the patch):
1) Ensure the identity store maps id 3 -> sa-1 and that the mapper exposes UIDScope(name) implementation.
2) Seed the same permission on role 1 for subject X with scope = "serviceaccounts:id:3".
3) Query the effective permissions for subject X.
4) Expect to see the permission exposed with the UID-scoped form: scope = "serviceaccounts:uid:sa-1" and action = "serviceaccounts:admin" (or corresponding human-readable action). This confirms the runtime translates the id-based scope to the correct UID-based scope before applying permission checks.
Concrete code outline (illustrative, not copy-paste runnable):
store := db.InitTestDB(t)
identity := NewFakeIdentityStore(t) // supports mapping from uid to uid-like identifiers
identity.AddMapping(3, "sa-1") // map id 3 -> sa-1
dbHelper := &legacysql.LegacyDatabaseHelper{DB: store, Table: func(name string) string { return name }}
dbProvider := func(ctx context.Context) (*legacysql.LegacyDatabaseHelper, error) { return dbHelper, nil }
backend := ProvideStorageBackend(dbProvider, saMapper()) // saMapper includes serviceaccounts with UID-based path
backend.identityStore = identity
// Seed permission with id-based scope
sess := store.GetSqlxSession()
_, err := sess.Exec(context.Background(),
`INSERT INTO permission (role_id, action, scope, created, updated) VALUES (?, ?, ?, ?, ?)`,
1, "serviceaccounts:admin", "serviceaccounts:id:3", "2025-09-02", "2025-09-02",
)
require.NoError(t, err)
// List direct permissions for subject
res, err := backend.ListDirectPermissionsForSubject(context.Background(), "default", "user-1")
require.NoError(t, err)
for _, p := range res {
fmt.Printf("%s -> %s\n", p.Action, p.Scope)
}
Expected output (post-fix):
- serviceaccounts:admin -> serviceaccounts:uid:sa-1
If the old behavior is observed (pre-fix), the scope may not resolve to the UID form, causing incorrect authorization results or missing grants.
Commit Details
Author: linoman
Date: 2026-05-13 19:07 UTC
Message:
IAM: fix resolveScope for id-scoped mappers and add service account resource permission tests (#124828)
* IAM: fix resolveScope for id-scoped mappers and add service account resource permission tests
resolveScope was building serviceaccounts:id:<uid> and passing it to
ResolveUIDScopeForWrite, which only handles the uid: prefix. Fix builds
the uid-scoped form first so the identity store lookup resolves correctly.
* IAM: address review comments on resolveScope and SA mapper tests
- Add UIDScope method to Mapper interface and mapper struct to avoid
fragile string manipulation in resolveScope
- Use mapper.UIDScope(name) in resolveScope instead of TrimSuffix
- Move SA mapper test to standalone function with fresh DB to avoid
shared-state ordering issues
- Replace inline struct literal with ProvideStorageBackend factory
- Add explanatory comment to saMapper() about NewMappersRegistry dependency
* IAM: fix gofmt formatting in sql_test.go
Triage Assessment
Vulnerability Type: Privilege escalation
Confidence: HIGH
Reasoning:
The commit changes how identity scopes are resolved for id-scoped resources by introducing a UIDScope method and adjusting resolveScope to use the UID-scoped form. This fixes potential mis-resolution of scopes when writing permissions, which directly impacts access control and could lead to improper permission grants. Added tests around service accounts further validate authorization behavior.
Verification Assessment
Vulnerability Type: Privilege escalation via incorrect identity scope resolution in IAM write path
Confidence: HIGH
Affected Versions: < 12.4.0 (pre-fix versions that rely on id-scoped mappers; the fix is included in 12.4.0)
Code Diff
diff --git a/pkg/registry/apis/iam/resourcepermission/mapper.go b/pkg/registry/apis/iam/resourcepermission/mapper.go
index a1d327ae9682..973c971b9234 100644
--- a/pkg/registry/apis/iam/resourcepermission/mapper.go
+++ b/pkg/registry/apis/iam/resourcepermission/mapper.go
@@ -35,6 +35,12 @@ type Mapper interface {
// Example: "folders:uid:%" matches "folders:uid:abc", "folders:uid:xyz", etc.
ScopePattern() string
+ // UIDScope returns the uid-form RBAC scope for a given resource name, regardless of the
+ // mapper's configured ScopeAttribute. Used by the write path to pass a uid-scoped string
+ // to ResolveUIDScopeForWrite for id-scoped resources.
+ // Example: UIDScope("123") returns "serviceaccounts:uid:123".
+ UIDScope(name string) 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
@@ -93,6 +99,10 @@ func (m mapper) Scope(name string) string {
return m.resource + ":" + string(m.scopeAttribute) + ":" + name
}
+func (m mapper) UIDScope(name string) string {
+ return m.resource + ":" + string(ScopeAttributeUID) + ":" + name
+}
+
func (m mapper) ActionSet(level string) (string, error) {
actionSet := m.resource + ":" + strings.ToLower(level)
if !slices.Contains(m.actionSets, actionSet) {
diff --git a/pkg/registry/apis/iam/resourcepermission/mapper_test.go b/pkg/registry/apis/iam/resourcepermission/mapper_test.go
index 5db0631e069e..fc868409e44b 100644
--- a/pkg/registry/apis/iam/resourcepermission/mapper_test.go
+++ b/pkg/registry/apis/iam/resourcepermission/mapper_test.go
@@ -417,3 +417,19 @@ func TestMapper_AllowsKind_RestrictedList(t *testing.T) {
assert.True(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindTeam))
assert.False(t, m.AllowsKind(v0alpha1.ResourcePermissionSpecPermissionKindBasicRole))
}
+
+func TestMapper_ActionSet_ServiceAccount(t *testing.T) {
+ m := NewMapperWithAttribute("serviceaccounts", []string{"Edit", "Admin"}, ScopeAttributeID, nil)
+
+ actionSet, err := m.ActionSet("Edit")
+ require.NoError(t, err)
+ assert.Equal(t, "serviceaccounts:edit", actionSet)
+
+ actionSet, err = m.ActionSet("Admin")
+ require.NoError(t, err)
+ assert.Equal(t, "serviceaccounts:admin", actionSet)
+
+ _, err = m.ActionSet("View")
+ require.Error(t, err)
+ require.ErrorIs(t, err, errInvalidSpec)
+}
diff --git a/pkg/registry/apis/iam/resourcepermission/sql.go b/pkg/registry/apis/iam/resourcepermission/sql.go
index b90c354d2c33..9880eb4de063 100644
--- a/pkg/registry/apis/iam/resourcepermission/sql.go
+++ b/pkg/registry/apis/iam/resourcepermission/sql.go
@@ -23,11 +23,12 @@ import (
// resolveScope returns the legacy db scope for the given resource name.
// For id-scoped resources (teams, users, service accounts), translates uid→id via the identity store.
func resolveScope(ctx context.Context, ns types.NamespaceInfo, store IdentityStore, mapper Mapper, name string) (string, error) {
- scope := mapper.Scope(name)
if isIDScoped(mapper) && store != nil {
- return legacy.ResolveUIDScopeForWrite(ctx, store, ns, scope)
+ // The resource name (grn.Name) is always a UID. UIDScope returns the uid-form
+ // scope so ResolveUIDScopeForWrite can translate it to the id-based equivalent.
+ return legacy.ResolveUIDScopeForWrite(ctx, store, ns, mapper.UIDScope(name))
}
- return scope, nil
+ return mapper.Scope(name), nil
}
// List
diff --git a/pkg/registry/apis/iam/resourcepermission/sql_test.go b/pkg/registry/apis/iam/resourcepermission/sql_test.go
index de736c79aaa4..30ed0ffa7c7b 100644
--- a/pkg/registry/apis/iam/resourcepermission/sql_test.go
+++ b/pkg/registry/apis/iam/resourcepermission/sql_test.go
@@ -724,7 +724,7 @@ func TestIntegration_ResourcePermSqlBackend_ListDirectPermissionsForSubject(t *t
name string
namespace string
subject string
- wantPerms []v0alpha1.PermissionSpec // action → scope
+ wantPerms []v0alpha1.PermissionSpec
wantNil bool
}{
{
@@ -792,6 +792,45 @@ func TestIntegration_ResourcePermSqlBackend_ListDirectPermissionsForSubject(t *t
}
}
+func TestIntegration_ResourcePermSqlBackend_ListDirectPermissionsForSubject_SAMapper(t *testing.T) {
+ testutil.SkipIntegrationTestInShortMode(t)
+
+ store := db.InitTestDB(t)
+ sqlHelper := &legacysql.LegacyDatabaseHelper{
+ DB: store,
+ Table: func(name string) string { return name },
+ }
+ dbProvider := func(ctx context.Context) (*legacysql.LegacyDatabaseHelper, error) {
+ return sqlHelper, nil
+ }
+
+ backend := ProvideStorageBackend(dbProvider, saMapper())
+ backend.identityStore = NewFakeIdentityStore(t)
+ setupTestRoles(t, store)
+
+ // Seed a serviceaccounts:id:3 permission onto user-1's role.
+ // The backend should resolve it to serviceaccounts:uid:sa-1 on read.
+ sess := store.GetSqlxSession()
+ _, err := sess.Exec(context.Background(),
+ `INSERT INTO permission (role_id, action, scope, created, updated) VALUES (?, ?, ?, ?, ?)`,
+ 1, "serviceaccounts:admin", "serviceaccounts:id:3", "2025-09-02", "2025-09-02",
+ )
+ require.NoError(t, err)
+
+ result, err := backend.ListDirectPermissionsForSubject(context.Background(), "default", "user-1")
+ require.NoError(t, err)
+
+ got := make(map[string]string, len(result))
+ for _, p := range result {
+ got[p.Action] = p.Scope
+ }
+ require.Equal(t, map[string]string{
+ "folders:view": "folders:uid:fold1",
+ "dashboards:edit": "dashboards:uid:dash1",
+ "serviceaccounts:admin": "serviceaccounts:uid:sa-1",
+ }, got)
+}
+
func TestIntegration_UpdateResourcePermission_VerbChange(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go
index aebccf39b85f..a30dea276eae 100644
--- a/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go
+++ b/pkg/registry/apis/iam/resourcepermission/storage_backend_test.go
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
@@ -265,6 +266,122 @@ func TestWriteEvent_Add(t *testing.T) {
})
}
+// saMapper builds a registry with the default folders/dashboards mappers (from NewMappersRegistry)
+// plus serviceaccounts. The folders/dashboards entries are load-bearing for the useSAMapper case
+// in sql_test.go — do not replace NewMappersRegistry() with a bare registry.
+func saMapper() *MappersRegistry {
+ m := NewMappersRegistry()
+ m.RegisterMapper(
+ saGroupResource(),
+ NewMapperWithAttribute("serviceaccounts", []string{"Edit", "Admin"}, ScopeAttributeID,
+ []v0alpha1.ResourcePermissionSpecPermissionKind{
+ v0alpha1.ResourcePermissionSpecPermissionKindUser,
+ v0alpha1.ResourcePermissionSpecPermissionKindServiceAccount,
+ v0alpha1.ResourcePermissionSpecPermissionKindTeam,
+ }),
+ nil,
+ )
+ return m
+}
+
+func saGroupResource() schema.GroupResource {
+ return schema.GroupResource{Group: "iam.grafana.app", Resource: "serviceaccounts"}
+}
+
+func TestWriteEvent_Add_ServiceAccount(t *testing.T) {
+ store := db.InitTestDB(t)
+
+ timeNow = func() time.Time {
+ return time.Date(2025, 8, 28, 17, 13, 0, 0, time.UTC)
+ }
+
+ sqlHelper := &legacysql.LegacyDatabaseHelper{
+ DB: store,
+ Table: func(name string) string { return name },
+ }
+ dbProvider := func(ctx context.Context) (*legacysql.LegacyDatabaseHelper, error) {
+ return sqlHelper, nil
+ }
+
+ t.Run("should write serviceaccount permission with user kind", func(t *testing.T) {
+ backend := ProvideStorageBackend(dbProvider, saMapper())
+ backend.identityStore = NewFakeIdentityStore(t)
+
+ resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "iam.grafana.app-serviceaccounts-robot",
+ Namespace: "default",
+ },
+ Spec: v0alpha1.ResourcePermissionSpec{
+ Resource: v0alpha1.ResourcePermissionspecResource{
+ ApiGroup: "iam.grafana.app",
+ Resource: "serviceaccounts",
+ Name: "robot",
+ },
+ Permissions: []v0alpha1.ResourcePermissionspecPermission{
+ {
+ Kind: v0alpha1.ResourcePermissionSpecPermissionKindUser,
+ Name: "captain",
+ Verb: "Edit",
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ gr := v0alpha1.ResourcePermissionInfo.GroupResource()
+ rv, err := backend.WriteEvent(context.Background(), resource.WriteEvent{
+ Type: resourcepb.WatchEvent_ADDED,
+ Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "iam.grafana.app-serviceaccounts-robot", Namespace: "default"},
+ Object: resourcePerm,
+ })
+ require.NoError(t, err)
+ require.Equal(t, timeNow().UnixMilli(), rv)
+
+ // Verify the permission was written with the ID-based scope
+ sess := store.GetSqlxSession()
+ var scope string
+ err = sess.Get(context.Background(), &scope, "SELECT scope FROM permission WHERE action = ? AND role_id = (SELECT id FROM role WHERE name = ?)", "serviceaccounts:edit", "managed:users:101:permissions")
+ require.NoError(t, err)
+ require.Equal(t, "serviceaccounts:id:201", scope)
+ })
+
+ t.Run("folder with basicRole kind still works after sa mapper registration", func(t *testing.T) {
+ backend := ProvideStorageBackend(dbProvider, saMapper())
+ backend.identityStore = NewFakeIdentityStore(t)
+
+ resourcePerm, err := utils.MetaAccessor(&v0alpha1.ResourcePermission{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "folder.grafana.app-folders-fold1",
+ Namespace: "default",
+ },
+ Spec: v0alpha1.ResourcePermissionSpec{
+ Resource: v0alpha1.ResourcePermissionspecResource{
+ ApiGroup: "folder.grafana.app",
+ Resource: "folders",
+ Name: "fold1",
+ },
+ Permissions: []v0alpha1.ResourcePermissionspecPermission{
+ {
+ Kind: v0alpha1.ResourcePermissionSpecPermissionKindBasicRole,
+ Name: "Viewer",
+ Verb: "Admin",
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ gr := v0alpha1.ResourcePermissionInfo.GroupResource()
+ _, err = backend.WriteEvent(context.Background(), resource.WriteEvent{
+ Type: resourcepb.WatchEvent_ADDED,
+ Key: &resourcepb.ResourceKey{Group: gr.Group, Resource: gr.Resource, Name: "folder.grafana.app-folders-fold1", Namespace: "default"},
+ Object: resourcePerm,
+ })
+ require.NoError(t, err)
+ })
+}
+
func TestIntegration_ResourcePermSqlBackend_ListIterator(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go
index 21b26fb230e7..855f0cc529ba 100644
--- a/pkg/services/accesscontrol/resourcepermissions/service_test.go
+++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go
@@ -690,6 +690,13 @@ func TestMapPermission_ServiceAccount(t *testing.T) {
require.Len(t, actions, 1)
assert.Equal(t, saOpts.GetActionSetName("Edit"), actions[0])
})
+
+ t.Run("invalid level returns ErrInvalidPermission", func(t *testing.T) {
+ svc := &Service{options: saOpts}
+ _, err := svc.mapPermission("View")
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrInvalidPermission)
+ })
}
func TestIsActionSetEnabledResource_ServiceAccount(t *testing.T) {