Authorization/Access Control
Description
The commit fixes an authorization bug in Grafana's RBAC translation by aligning org.users:* actions with the global users:* actions for both read and write scopes. Previously, org-level user-management permissions (org.users:read, org.users:write, org.users:remove) were not consistently reflected in the legacy permissions (users:id:*), which could cause org-level grants not to propagate correctly and lead to misconfigurations or privilege inconsistencies. The patch updates isUserRBACAction and userScopeKind to treat org.users:* actions as org-scoped within the users kind and extends the translation table to include org.users:read, org.users:write, and org.users:remove. This ensures proper reflection of org-level grants back to legacy permissions, improving authorization correctness and reducing potential privilege misconfigurations.
Proof of Concept
// Proof of concept illustrating the mapping after the fix
// This demonstrates that an org-level org.users:* action maps to the users scope as expected.
// In Grafana's authorization resolver, a request that uses org.users:read with All=true should yield
// a permission entry of Action: org.users:read, Scope: users:id:*
/* Go test-style PoC snippet (conceptual example):
package zanzana_poc_test
import (
"testing"
"github.com/stretchr/testify/require"
ac "path/to/pkg/services/accesscontrol/acimpl" // adjust import path as needed
authzv1 "path/to/pkg/apis/authz/v1" // adjust import path as needed
)
func TestOrgUsersReadMapsToUsersIDStar(t *testing.T) {
perms, err := zanzanaResolve(&authzv1.ListResponse{All: true}, "org.users:read", "")
require.NoError(t, err)
require.Equal(t, []ac.Permission{
{ Action: "org.users:read", Scope: "users:id:*" },
}, perms)
}
func TestOrgUsersWriteMapsToUsersIDStar(t *testing.T) {
perms, err := zanzanaResolve(&authzv1.ListResponse{All: true}, "org.users:write", "")
require.NoError(t, err)
require.Equal(t, []ac.Permission{
{ Action: "org.users:write", Scope: "users:id:*" },
}, perms)
}
func TestOrgUsersRemoveMapsToUsersIDStar(t *testing.T) {
perms, err := zanzanaResolve(&authzv1.ListResponse{All: true}, "org.users:remove", "")
require.NoError(t, err)
require.Equal(t, []ac.Permission{
{ Action: "org.users:remove", Scope: "users:id:*" },
}, perms)
}
*/
Commit Details
Author: Cory Forseth
Date: 2026-06-26 21:54 UTC
Message:
RBAC: account for org.users permissions when merging from zanzana (#127345)
The org.users:* family (org.users:read/write/remove) gates the same
iam.grafana.app/users verbs as the global users:* family on the write side
(userManagementMappings in tuple_helpers.go), but the read/merge path did not
list them, so org-level user-admin grants were never reflected back as legacy
permissions.
Add org.users:read/write/remove to the KindUsers translation, match the
org.users: prefix in isUserRBACAction, and scope the family to the org-level
users kind in userScopeKind (users:id:*), matching roles.go and the docs.
org.users:add is omitted, mirroring the write-side mapping.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Triage Assessment
Vulnerability Type: Authorization/Access Control
Confidence: MEDIUM
Reasoning:
The commit expands RBAC mappings to correctly reflect org-level (org.users) permissions in the access control layer. By aligning org.users:* with global users:* actions and ensuring org-level grants propagate as legacy permissions, it reduces the risk of improper permission grants or revocation due to mis-scoped or missing mappings. This directly affects authorization correctness and helps prevent potential privilege escalation or privilege misconfiguration related to user management actions.
Verification Assessment
Vulnerability Type: Authorization/Access Control
Confidence: MEDIUM
Affected Versions: < 12.4.0
Code Diff
diff --git a/pkg/services/accesscontrol/acimpl/zanzana_resolver.go b/pkg/services/accesscontrol/acimpl/zanzana_resolver.go
index 194a7d4907e52..5a85edaaef4ca 100644
--- a/pkg/services/accesscontrol/acimpl/zanzana_resolver.go
+++ b/pkg/services/accesscontrol/acimpl/zanzana_resolver.go
@@ -273,18 +273,21 @@ func teamWildcardScope() string {
return ac.Scope("teams", "id", "*")
}
-// isUserRBACAction matches user-management actions.
+// isUserRBACAction matches user-management actions, including the org.users:* family.
func isUserRBACAction(action string) bool {
- return strings.HasPrefix(action, "users:") || strings.HasPrefix(action, "users.")
+ return strings.HasPrefix(action, "users:") || strings.HasPrefix(action, "users.") ||
+ strings.HasPrefix(action, "org.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.
+// users.permissions:write all grant global.users:* in the fixed roles). The org-level
+// exceptions, scoped to the users kind, are users.permissions:read (fixed:org.users:reader)
+// and the org.users:* family (fixed:org.users:reader/writer).
func userScopeKind(action string) string {
- if action == ac.ActionUsersPermissionsRead {
+ // users.permissions:read and the org.users:* family are org-scoped (the users kind);
+ // the remaining user-management actions are server-level (global.users).
+ if action == ac.ActionUsersPermissionsRead || strings.HasPrefix(action, "org.users:") {
return "users"
}
return "global.users"
diff --git a/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go b/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go
index 66a27de612e5d..475c727e61fce 100644
--- a/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go
+++ b/pkg/services/accesscontrol/acimpl/zanzana_resolver_test.go
@@ -886,6 +886,17 @@ func TestListPermissions_UserActions(t *testing.T) {
}, perms)
})
+ // The org.users:* family is org-scoped (users kind), like users.permissions:read.
+ for _, action := range []string{"org.users:read", "org.users:write", "org.users:remove"} {
+ t.Run(action+" All=true produces 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: "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)
diff --git a/pkg/services/authz/zanzana/common/translations.go b/pkg/services/authz/zanzana/common/translations.go
index 82246224a48f7..2f12dc30b1917 100644
--- a/pkg/services/authz/zanzana/common/translations.go
+++ b/pkg/services/authz/zanzana/common/translations.go
@@ -146,6 +146,12 @@ var resourceTranslations = map[string]resourceTranslation{
"users:delete": newMapping(RelationDelete, ""),
"users.permissions:read": newMapping(RelationGetPermissions, ""),
"users.permissions:write": newMapping(RelationSetPermissions, ""),
+ // The org.users:* family gates the same iam.grafana.app/users verbs as the
+ // global users:* family (see userManagementMappings in tuple_helpers.go).
+ // org.users:add is intentionally omitted, matching the write-side mapping.
+ "org.users:read": newMapping(RelationGet, ""),
+ "org.users:write": newMapping(RelationUpdate, ""),
+ "org.users:remove": newMapping(RelationDelete, ""),
},
},
}