Access Control / RBAC
Description
The commit fixes RBAC/authorization behavior for user-management actions in the Zanzana resolver. Previously, scope translation for user-related actions could be mis-scoped due to hardcoded group/version/resource and simplistic UID-to-ID translation. The patch derives IAM GVRs from resource info (avoiding drift from hardcoded iam.grafana.com) and adds explicit handling to map user-related actions to the correct legacy RBAC scope (global.users vs users), including a special case for users.permissions:read (org-level) and a UID-to-ID translation path for users actions. Tests accompany the change to validate correct scoping and translations. This reduces the risk of over-privilege or incorrect access via mis-scoped permissions in legacy RBAC.
Commit Details
Author: Cory Forseth
Date: 2026-06-25 21:31 UTC
Message:
RBAC: merge user admin permissions from zanzana (#127199)
* merge user admin permissions from zanzana
* RBAC: scope merged user permissions to global.users
User-management actions in legacy RBAC are scoped to the global.users kind,
not users. Map merged Zanzana user permissions accordingly: users:read/write/
delete and users.permissions:write resolve to global.users:id:<n> /
global.users:id:*. users.permissions:read is the org-level exception
(fixed:org.users:reader) and stays on the users kind.
Threads the action through resolveUserScope/userWildcardScope so the scope
kind is selected per action, and updates the unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* RBAC: derive IAM GVRs from resource info
Derive the team/user GroupVersionResource from iamv0.TeamResourceInfo /
UserResourceInfo instead of hardcoding group/version/resource. The hardcoded
form had previously drifted to the wrong group (iam.grafana.com); deriving from
the resource info keeps it in sync with what the apiserver serves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Triage Assessment
Vulnerability Type: Access Control / RBAC
Confidence: HIGH
Reasoning:
The patch adds nuanced RBAC scoping for user-management actions, derives resource identifiers from actual IAM resource info (instead of hardcoded values), and translates user UIDs to IDs for correct legacy RBAC scope resolution. These changes tighten and correct authorization checks for user-related actions, reducing risk of over-privilege or incorrect access via mis-scoped permissions. Tests accompany these changes to verify correct scopes. This constitutes a security fix in access control behavior (RBAC).
Verification Assessment
Vulnerability Type: Access Control / RBAC
Confidence: HIGH
Affected Versions: <=12.4.0 (prior to this patch)
Code Diff
diff --git a/pkg/services/accesscontrol/acimpl/zanzana_resolver.go b/pkg/services/accesscontrol/acimpl/zanzana_resolver.go
index ff2e2246ff69b..f75a5161bf4ec 100644
--- a/pkg/services/accesscontrol/acimpl/zanzana_resolver.go
+++ b/pkg/services/accesscontrol/acimpl/zanzana_resolver.go
@@ -14,6 +14,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
+ iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
@@ -269,6 +270,44 @@ func teamWildcardScope() string {
return ac.Scope("teams", "id", "*")
}
+// isUserRBACAction matches user-management actions.
+func isUserRBACAction(action string) bool {
+ return strings.HasPrefix(action, "users:") || strings.HasPrefix(action, "users.")
+}
+
+// userScopeKind returns the legacy RBAC scope kind for a user-management action.
+// Most user actions are server-level and scoped to global.users (users:read/write/delete and
+// users.permissions:write all grant global.users:* in the fixed roles). users.permissions:read is
+// the exception: it gates org-level permission listing (fixed:org.users:reader) and is scoped to
+// the org-level users kind.
+func userScopeKind(action string) string {
+ if action == ac.ActionUsersPermissionsRead {
+ return "users"
+ }
+ return "global.users"
+}
+
+// resolveUserScope translates a user UID to the legacy <kind>:id:<numericID> scope for the action.
+func (r *ZanzanaPermissionResolver) resolveUserScope(ctx context.Context, namespace, action, userUID string) (string, error) {
+ if r.scopeResolver == nil {
+ return "", errors.New("scope resolver not initialized")
+ }
+ nsInfo, err := types.ParseNamespace(namespace)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse namespace: %w", err)
+ }
+ id, err := r.scopeResolver.GetUserIDByUID(ctx, nsInfo, userUID)
+ if err != nil {
+ return "", err
+ }
+ return ac.Scope(userScopeKind(action), "id", fmt.Sprintf("%d", id)), nil
+}
+
+// userWildcardScope returns the legacy wildcard scope for a user action (<kind>:id:*).
+func userWildcardScope(action string) string {
+ return ac.Scope(userScopeKind(action), "id", "*")
+}
+
// listPermissions lists permissions for a subject on a given group/resource
func (r *ZanzanaPermissionResolver) listPermissions(ctx context.Context, namespace, subject string, teams []string, group, resource, verb, action, scope string) ([]ac.Permission, error) {
req := &authzv1.ListRequest{
@@ -337,6 +376,11 @@ func (r *ZanzanaPermissionResolver) listPermissions(ctx context.Context, namespa
Action: action,
Scope: teamWildcardScope(),
})
+ } else if isUserRBACAction(action) {
+ appendIfMatches(ac.Permission{
+ Action: action,
+ Scope: userWildcardScope(action),
+ })
} else {
appendIfMatches(ac.Permission{
Action: action,
@@ -346,10 +390,12 @@ func (r *ZanzanaPermissionResolver) listPermissions(ctx context.Context, namespa
}
// Items are objects of the listed resource type, scoped by that resource.
- // Team actions need UID→ID translation so scopes match legacy RBAC (teams:id:<n>).
+ // Team and user actions need UID→ID translation so scopes match legacy RBAC
+ // (teams:id:<n> / users:id:<n>).
for _, item := range resp.Items {
var itemScope string
- if isTeamRBACAction(action) {
+ switch {
+ case isTeamRBACAction(action):
resolved, err := r.resolveTeamScope(ctx, namespace, item)
if err != nil {
zLogger.Warn("failed to resolve team UID to ID, using uid scope", "uid", item, "error", err)
@@ -357,7 +403,15 @@ func (r *ZanzanaPermissionResolver) listPermissions(ctx context.Context, namespa
} else {
itemScope = resolved
}
- } else {
+ case isUserRBACAction(action):
+ resolved, err := r.resolveUserScope(ctx, namespace, action, item)
+ if err != nil {
+ zLogger.Warn("failed to resolve user UID to ID, using uid scope", "uid", item, "error", err)
+ itemScope = resourceScope(resource, item)
+ } else {
+ itemScope = resolved
+ }
+ default:
itemScope = resourceScope(resource, item)
}
appendIfMatches(ac.Permission{
@@ -509,11 +563,12 @@ func (r *ZanzanaPermissionResolver) MergeSearch(ctx context.Context, usr identit
return MergePermissions(legacy, zPerms)
}
-var teamGVR = schema.GroupVersionResource{
- Group: "iam.grafana.com",
- Version: "v0alpha1",
- Resource: "teams",
-}
+// GVRs are derived from the IAM resource info so the group/version/resource always match what
+// the apiserver serves, instead of hardcoding (which previously drifted to the wrong group).
+var (
+ teamGVR = iamv0.TeamResourceInfo.GroupVersionResource()
+ userGVR = iamv0.UserResourceInfo.GroupVersionResource()
+)
type uidToIDResolver struct {
mu sync.RWMutex
@@ -580,3 +635,7 @@ func (r *uidToIDResolver) getObjectID(ctx context.Context, nsInfo types.Namespac
func (r *uidToIDResolver) GetTeamIDByUID(ctx context.Context, nsInfo types.NamespaceInfo, uid string) (int64, error) {
return r.getObjectID(ctx, nsInfo, teamGVR, uid)
}
+
+func (r *uidToIDResolver) GetUserIDByUID(ctx context.Context, nsInfo types.NamespaceInfo, uid string) (int64, error) {
+ return r.getObjectID(ctx, nsInfo, userGVR, uid)
+}
diff --git a/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go b/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go
index ddf68f8195015..66a27de612e5d 100644
--- a/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go
+++ b/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go
@@ -370,11 +370,11 @@ func TestSearchPermissionsForIdentity_UnsupportedAction_ReturnsEmpty(t *testing.
42,
"user-uid",
false,
- ac.SearchOptions{Action: "users:read"},
+ ac.SearchOptions{Action: "datasources:read"},
)
require.NoError(t, err)
- // users:read is not in the Zanzana translation table, so no List call
+ // datasources:read is not in the Zanzana translation table, so no List call
// should be made and the result should be empty.
require.Empty(t, result)
require.Empty(t, fake.listCalls, "should not call Zanzana List for untranslatable actions")
@@ -862,6 +862,39 @@ func TestListPermissions_TeamActions(t *testing.T) {
})
}
+// TestListPermissions_UserActions verifies user actions produce legacy id-based scopes.
+// Most user actions are scoped to the global.users kind; users.permissions:read is the org-level
+// exception scoped to the users kind. Without a scope resolver (nil in unit tests), items fall
+// back to the Zanzana resource scope users:uid:<name>.
+func TestListPermissions_UserActions(t *testing.T) {
+ // Server-level actions are scoped to global.users.
+ for _, action := range []string{"users:read", "users:write", "users:delete", "users.permissions:write"} {
+ t.Run(action+" All=true produces global.users:id:* wildcard", func(t *testing.T) {
+ perms, err := zanzanaResolve(&authzv1.ListResponse{All: true}, action, "")
+ require.NoError(t, err)
+ require.Equal(t, []ac.Permission{
+ {Action: action, Scope: "global.users:id:*"},
+ }, perms)
+ })
+ }
+
+ t.Run("users.permissions:read is the org-level exception scoped to users:id:*", func(t *testing.T) {
+ perms, err := zanzanaResolve(&authzv1.ListResponse{All: true}, "users.permissions:read", "")
+ require.NoError(t, err)
+ require.Equal(t, []ac.Permission{
+ {Action: "users.permissions:read", Scope: "users:id:*"},
+ }, perms)
+ })
+
+ t.Run("items fall back to uid scope when scope resolver is nil", func(t *testing.T) {
+ perms, err := zanzanaResolve(&authzv1.ListResponse{Items: []string{"user-abc"}}, "users:read", "")
+ require.NoError(t, err)
+ require.Equal(t, []ac.Permission{
+ {Action: "users:read", Scope: "users:uid:user-abc"},
+ }, perms)
+ })
+}
+
// TestListPermissions_PermissionManagementActionsScoping pins how *.permissions:*
// actions translate: scoped by their base resource, and for dashboards either
// dashboard-scoped (direct grant) or folder-scoped (inherited from the folder).
diff --git a/pkg/services/authz/zanzana/common/translations.go b/pkg/services/authz/zanzana/common/translations.go
index 97931dd22ca97..82246224a48f7 100644
--- a/pkg/services/authz/zanzana/common/translations.go
+++ b/pkg/services/authz/zanzana/common/translations.go
@@ -73,6 +73,7 @@ var (
iamGroup = iamv0.TeamResourceInfo.GroupResource().Group
teamsResource = iamv0.TeamResourceInfo.GroupResource().Resource
+ usersResource = iamv0.UserResourceInfo.GroupResource().Resource
)
var resourceTranslations = map[string]resourceTranslation{
@@ -134,6 +135,19 @@ var resourceTranslations = map[string]resourceTranslation{
"teams.permissions:write": newMapping(RelationSetPermissions, ""),
},
},
+ KindUsers: {
+ typ: TypeUser,
+ group: iamGroup, // "iam.grafana.app"
+ resource: usersResource, // "users"
+ mapping: map[string]actionMapping{
+ "users:read": newMapping(RelationGet, ""),
+ "users:write": newMapping(RelationUpdate, ""),
+ "users:create": newUnscopedMapping(RelationCreate),
+ "users:delete": newMapping(RelationDelete, ""),
+ "users.permissions:read": newMapping(RelationGetPermissions, ""),
+ "users.permissions:write": newMapping(RelationSetPermissions, ""),
+ },
+ },
}
func TranslateToCheckRequest(namespace, action, kind, name string) (*authlib.CheckRequest, bool) {
diff --git a/pkg/services/authz/zanzana/schema/schema_core.fga b/pkg/services/authz/zanzana/schema/schema_core.fga
index d6d97b497c717..c7382d561aa2a 100644
--- a/pkg/services/authz/zanzana/schema/schema_core.fga
+++ b/pkg/services/authz/zanzana/schema/schema_core.fga
@@ -6,6 +6,9 @@ type user
define update: [user, service-account, team#member, role#assignee]
define delete: [user, service-account, team#member, role#assignee]
+ define get_permissions: [user, service-account, team#member, role#assignee]
+ define set_permissions: [user, service-account, team#member, role#assignee]
+
type service-account
relations
define get: [user, service-account, team#member, role#assignee]