Authorization bypass / RBAC enforcement on unified storage search (IAM users resource)

HIGH
grafana/grafana
Commit: ec005b849029
Affected: <=12.4.0
2026-05-27 10:34 UTC

Description

RBAC enforcement for the iam.grafana.app/users resource in the unified storage search path was missing, allowing potential authorization bypass via /api/users/search. The commit enforces RBAC by extending the access control allowlist to include iam.grafana.app: { users: nil } and adds tests that verify correct authorization behavior for global admin, org editor, and scoped user scenarios. This addresses an authorization bypass risk where users could enumerate other users through the unified storage search filter.

Proof of Concept

PoC steps to reproduce the vulnerability prior to this fix (exploitation path): Prerequisites: - Grafana instance using unified storage (unified storage config enabled). - Two test users created by an admin: alpha-authz and beta-authz. - An editor user in the same org without the appropriate users:read permission exposed via RBAC gating. - A valid bearer token for the editor user and for the admin user (for authenticated API calls). 1) Admin creates two test users (alpha and beta) so they exist in the system: curl -X POST https://grafana.example/api/admin/users \ -H "Authorization: Bearer <ADMIN_TOKEN>" \ -H 'Content-Type: application/json' \ -d '{"name":"alpha-authz","login":"alpha-authz","email":"alpha-authz@example.com","password":"Password123"}' curl -X POST https://grafana.example/api/admin/users \ -H "Authorization: Bearer <ADMIN_TOKEN>" \ -H 'Content-Type: application/json' \ -d '{"name":"beta-authz","login":"beta-authz","email":"beta-authz@example.com","password":"Password123"}' 2) Attempt to list users with an editor (who should not have users:read if the RBAC check is enforced by route gate): curl -X GET "https://grafana.example/api/users/search?perpage=100" \ -H "Authorization: Bearer <EDITOR_TOKEN>" Expected BEFORE FIX (vulnerability): 200 OK with a list containing alpha-authz and beta-authz (indicating an authorization bypass allowing user enumeration). Expected AFTER FIX: 403 Forbidden or a limited result set in line with the editor’s permissions. 3) For completeness, verify admin can list all users: curl -X GET "https://grafana.example/api/users/search?perpage=100" \ -H "Authorization: Bearer <ADMIN_TOKEN>" Expected: 200 OK with both alpha-authz and beta-authz present. 4) Test scoped user behavior (after fix): create a scoped user with permissions limited to a single user (alpha-authz) and verify that /api/users/search only returns that user for that scoped caller. The test flow mirrors the patch’s intent and is not executed here, but the expected outcome is that only alpha-authz appears in the results when using the scoped user’s token. Notes: - If the vulnerability existed, step 2 would demonstrate that an editor (or other less-privileged user) could enumerate other users via the unified search API without the correct RBAC gating. The fix adds iam.grafana.app -> users to the allowlist in the RBAC layer and adds tests to ensure proper authorization checks are applied to /api/users/search. - In a environment with the patch applied, the step 2 request should return 403 or a restricted payload, while step 3 remains 200 for admins and step 4 validates scoped access as per the new tests.

Commit Details

Author: Mihai Doarna

Date: 2026-05-27 10:05 UTC

Message:

IAM: Enforce RBAC on iam.grafana.app/users in the unified storage search filter (#124906) * add user search authorization tests * add ActionOrgUsersRead permission * add iam app to authz client and enable all modes in tests * enable modes 4-5 for scoped user test * run the test only for modes 0, 1 and 5

Triage Assessment

Vulnerability Type: Authorization bypass / RBAC enforcement

Confidence: HIGH

Reasoning:

The patch enforces RBAC for the iam.grafana.app/users resource in the unified storage search filter by adding explicit access control for this resource and extending tests to validate authorization. This prevents unauthorized users from listing other users via the /api/users/search route in unified storage, addressing an authorization bypass risk.

Verification Assessment

Vulnerability Type: Authorization bypass / RBAC enforcement on unified storage search (IAM users resource)

Confidence: HIGH

Affected Versions: <=12.4.0

Code Diff

diff --git a/pkg/storage/unified/resource/access.go b/pkg/storage/unified/resource/access.go index a32eea6e9a721..3308b4da4ba1f 100644 --- a/pkg/storage/unified/resource/access.go +++ b/pkg/storage/unified/resource/access.go @@ -103,6 +103,7 @@ func NewAuthzLimitedClient(client claims.AccessClient, opts AuthzOptions) claims allowlist: groupResource{ "dashboard.grafana.app": map[string]interface{}{"dashboards": nil}, "folder.grafana.app": map[string]interface{}{"folders": nil}, + "iam.grafana.app": map[string]interface{}{"users": nil}, }, logger: logger, metrics: newMetrics(opts.Registry), diff --git a/pkg/tests/apis/iam/user/user_service_integration_test.go b/pkg/tests/apis/iam/user/user_service_integration_test.go index f0e4d461f18fb..f516323867d0a 100644 --- a/pkg/tests/apis/iam/user/user_service_integration_test.go +++ b/pkg/tests/apis/iam/user/user_service_integration_test.go @@ -2,13 +2,17 @@ package user import ( "fmt" + "strconv" "testing" "time" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -565,3 +569,151 @@ func TestIntegrationUserServiceSearch(t *testing.T) { }) } } + +// go test --tags "pro" -timeout 120s -run ^TestIntegrationUserServiceSearchAuthorization$ github.com/grafana/grafana/pkg/tests/apis/iam -count=1 +func TestIntegrationUserServiceSearchAuthorization(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + type searchUserHit struct { + UID string `json:"uid"` + Login string `json:"login"` + } + + type searchUsersResponse struct { + TotalCount int64 `json:"totalCount"` + Users []searchUserHit `json:"users"` + } + + type createUserResponse struct { + ID int64 `json:"id"` + UID string `json:"uid"` + } + + for _, mode := range []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode5} { + t.Run(fmt.Sprintf("dual writer mode %d", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: "unified", + RBACSingleOrganization: true, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "users.iam.grafana.app": {DualWriterMode: mode}, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagKubernetesUsersApi, + featuremgmt.FlagKubernetesUsersRedirect, + }, + }) + + alphaResp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "POST", + Path: "/api/admin/users", + Body: []byte(`{"name": "alpha-authz", "email": "alpha-authz@example.com", "login": "alpha-authz", "password": "password123"}`), + }, &createUserResponse{}) + require.Equal(t, 200, alphaResp.Response.StatusCode, "body: %s", string(alphaResp.Body)) + require.NotZero(t, alphaResp.Result.ID, "alpha create returned zero ID; body=%s", string(alphaResp.Body)) + alphaID := alphaResp.Result.ID + + betaResp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "POST", + Path: "/api/admin/users", + Body: []byte(`{"name": "beta-authz", "email": "beta-authz@example.com", "login": "beta-authz", "password": "password123"}`), + }, &createUserResponse{}) + require.Equal(t, 200, betaResp.Response.StatusCode, "body: %s", string(betaResp.Body)) + + // The scoped user needs two grants to exercise the redirect + filter chain + // end-to-end: + // 1) users:read — required by the /api/users/search route gate (see the + // editor_route_gate sub-test above). Any scope is fine here; the + // gate only checks the action. + // 2) org.users:read scoped to a single user — the data filter consults + // this. The scope format depends on which filter serves the + // response: + // Mode 0-3 (legacy SQL filter): accesscontrol.Filter at + // pkg/services/org/orgimpl/store.go:555 expects "users:id:" and + // builds `org_user.user_id IN (alphaID)`. + // Mode 4-5 (bleve via authzLimitedClient -> RBAC): the iam/users + // mapper uses attribute "uid", and the scope resolver translates + // stored "users:id:<N>" by reading the legacy SQL `user` table. + // In unified-only modes alpha isn't in legacy SQL, so we store + // the scope already in "users:uid:<UID>" form to bypass the + // resolver. + orgUsersReadScopeAttr := "id" + orgUsersReadScopeID := strconv.FormatInt(alphaID, 10) + if mode >= rest.Mode4 { + orgUsersReadScopeAttr = "uid" + orgUsersReadScopeID = alphaResp.Result.UID + } + scopedUser := helper.CreateUser( + "scoped-search-user", + apis.Org1, + org.RoleEditor, + []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{accesscontrol.ActionUsersRead}, + Resource: "global.users", + ResourceAttribute: "id", + ResourceID: strconv.FormatInt(alphaID, 10), + }, + { + Actions: []string{accesscontrol.ActionOrgUsersRead}, + Resource: "users", + ResourceAttribute: orgUsersReadScopeAttr, + ResourceID: orgUsersReadScopeID, + }, + }, + ) + + // Wait for the search index to pick up the new users. + time.Sleep(2 * time.Second) + + t.Run("server admin sees all users via the K8s redirect", func(t *testing.T) { + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "GET", + Path: "/api/users/search?perpage=100", + }, &searchUsersResponse{}) + require.Equal(t, 200, rsp.Response.StatusCode, "body: %s", string(rsp.Body)) + + logins := make([]string, 0, len(rsp.Result.Users)) + for _, u := range rsp.Result.Users { + logins = append(logins, u.Login) + } + require.Contains(t, logins, "alpha-authz", "server admin should see alpha-authz; got: %v", logins) + require.Contains(t, logins, "beta-authz", "server admin should see beta-authz; got: %v", logins) + }) + + t.Run("editor without users:read is denied at the route gate", func(t *testing.T) { + // Org1.Editor holds org.users:read but no users:read. The + // /api/users/search route gate requires ActionUsersRead, so it + // 403s before reaching the K8s redirect or the bleve filter. + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Editor, + Method: "GET", + Path: "/api/users/search", + }, &searchUsersResponse{}) + require.Equal(t, 403, rsp.Response.StatusCode, "body: %s", string(rsp.Body)) + }) + + t.Run("scoped users:read returns only the allowed user", func(t *testing.T) { + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: scopedUser, + Method: "GET", + Path: "/api/users/search?perpage=100", + }, &searchUsersResponse{}) + require.Equal(t, 200, rsp.Response.StatusCode, "body: %s", string(rsp.Body)) + + logins := make([]string, 0, len(rsp.Result.Users)) + for _, u := range rsp.Result.Users { + logins = append(logins, u.Login) + } + require.Contains(t, logins, "alpha-authz", "scoped user should see their allowed target; got: %v", logins) + require.NotContains(t, logins, "beta-authz", "scoped user must not see beta (users:read is scoped to alpha); got: %v", logins) + require.Len(t, rsp.Result.Users, 1, "scoped user should see exactly one user; got: %v", logins) + }) + }) + } +}
← Back to Alerts View on GitHub →