# REPRO-2026-00227: Grafana unified storage IAM service account listing lacked RBAC filtering, allowing low-privileged users to enumerate service accounts. ## Summary Status: published Severity: high Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00227 ## Package Name: grafana/grafana Ecosystem: Go Affected: Versions prior to commit 8891796ca1086cd234e1715ea71d8db0073cc160 (fix adds RBAC allowlist for serviceaccounts) Fixed: Commit 8891796ca1086cd234e1715ea71d8db0073cc160 ## Root Cause # Root Cause Analysis — Grafana unified-storage service account list RBAC bypass ## Summary Grafana's unified-storage authorization shim, `authzLimitedClient` in `pkg/storage/unified/resource/access.go`, only delegates authorization checks to RBAC for resources present in an internal allowlist. Before fix commit `8891796ca1086cd234e1715ea71d8db0073cc160`, the `iam.grafana.app` allowlist covered `users` and `teams` but omitted `serviceaccounts`. For service account list/search/read operations, the storage layer therefore treated the resource as not RBAC-compatible and returned an allow-all list checker. In a real Grafana unified-storage HTTP server, a low-privileged user with a scoped `serviceaccounts:read` grant for only `alpha-sa` can call `GET /apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts` and receive both `alpha-sa` and unauthorized `beta-sa` on the vulnerable build; the fixed build filters the response to only `alpha-sa`. ## Impact - **Package/component affected:** Grafana unified storage, specifically `pkg/storage/unified/resource/access.go` (`authzLimitedClient`) and the IAM service account list path backed by unified storage. - **Affected versions:** Builds before `8891796ca1086cd234e1715ea71d8db0073cc160` where the `iam.grafana.app` allowlist is `{"users": nil, "teams": nil}` and lacks `"serviceaccounts"`. The reproduction anchors the vulnerable build to the fixed commit's parent, `c00083433312adb7b7cfef83f74751e1216f67f8`, per the fixed-commit rule. - **Risk level and consequences:** Authorization bypass / information disclosure. A low-privileged authenticated user who can pass the top-level list gate with a limited service-account read grant can enumerate service account objects outside the grant's scope because storage-layer per-item filtering is skipped. ## Impact Parity - **Disclosed/claimed maximum impact:** `authz_bypass` over a remote/API surface: service account enumeration through `GET /apis/iam.grafana.app/.../namespaces/{org}/serviceaccounts` by a low-privileged authenticated user without permission for the enumerated service account(s). - **Reproduced impact from this run:** `authz_bypass` through the real Grafana HTTP/API path. The script starts a real Grafana test server with unified storage and Mode5 for `serviceaccounts.iam.grafana.app`, creates `alpha-sa` and `beta-sa`, creates a low-privileged Viewer user with only `serviceaccounts:read` scoped to `alpha-sa`, then performs a raw HTTP `GET /apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts` as that user. Vulnerable build returns `alpha-sa` and unauthorized `beta-sa`; fixed build returns only `alpha-sa`. - **Parity:** `full` for the product-facing API authorization-bypass class. The caller is low-privileged and lacks permission to read `beta-sa`, yet receives it only on the vulnerable build. - **Not demonstrated:** The repository currently serves IAM APIs as `v0alpha1` only (`apps/iam/pkg/apis/iam_manifest.go` has `PreferredVersion: "v0alpha1"` and no `v1` service account API). Therefore the concrete exercised path is the currently implemented service account endpoint, `/apis/iam.grafana.app/v0alpha1/namespaces/{org}/serviceaccounts`, matching the judge feedback's requested v0alpha1 surface rather than a non-existent v1 route in this checkout. ## Root Cause `authzLimitedClient` is designed as a temporary bridge that only sends certain group/resource pairs to the underlying RBAC access client. The vulnerable allowlist omits `serviceaccounts`: ```go "iam.grafana.app": map[string]interface{}{"users": nil, "teams": nil}, ``` The affected methods first call `IsCompatibleWithRBAC`. If it returns `false`, they skip the underlying access client: - `Check` returns `claims.CheckResponse{Allowed: true}`. - `Compile` returns `func(name, folder string) bool { return true }`, so list filtering allows every object. - `BatchCheck` marks each item allowed. The product HTTP path reaches this through the IAM service account list implementation. `pkg/registry/apis/iam/serviceaccount/store.go` calls `common.List`, and `common.List` calls `ac.Compile(...)` to obtain an item checker for the list response. In the vulnerable build, `Compile` returns an always-true checker for `iam.grafana.app/serviceaccounts`, so `beta-sa` is not filtered out. In the fixed build, `serviceaccounts` is in the allowlist, so `Compile` delegates to RBAC and the scoped low-privileged user only sees `alpha-sa`. Fix commit: - `8891796ca1086cd234e1715ea71d8db0073cc160` — "Unified storage: Enforce RBAC for serviceaccount search/list/read (#127839)" The relevant patch is the one-line allowlist addition: ```diff -"iam.grafana.app": map[string]interface{}{"users": nil, "teams": nil}, +"iam.grafana.app": map[string]interface{}{"users": nil, "teams": nil, "serviceaccounts": nil}, ``` ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh`. 2. The script: - Reads `bundle/project_cache_context.json` and reuses the stable Grafana checkout at `/repo`. - Uses the fixed commit `8891796ca1086cd234e1715ea71d8db0073cc160` and its parent `c00083433312adb7b7cfef83f74751e1216f67f8`. - Verifies the vulnerable checkout lacks the `serviceaccounts` allowlist entry and the fixed checkout contains it. - Runs a supporting real-code library test against `authzLimitedClient` on both versions. - Compiles and runs a Grafana IAM service account integration test that starts a real Grafana HTTP server, creates service accounts/users/permissions, and issues the actual HTTP GET to the service account list endpoint as a low-privileged scoped user. 3. Expected evidence: - Vulnerable HTTP run: `HTTP_LIST_RESULT_JSON {"items":2,"names":["alpha-sa","beta-sa"],..."status":200}` and `BEHAVIOUR: VULNERABLE_HTTP`. - Fixed HTTP run: `HTTP_LIST_RESULT_JSON {"items":1,"names":["alpha-sa"],..."status":200}` and `BEHAVIOUR: FIXED_HTTP`. - Script exits `0` only when both the vulnerable product exposure and fixed negative control are observed. ## Evidence - `bundle/logs/reproduction_steps.log` — first successful full run. - `bundle/logs/reproduction_steps_second.log` — second consecutive successful full run. - `bundle/logs/evidence.log` — annotated evidence from the most recent script run. - `bundle/logs/api_http_vulnerable.log` — vulnerable real Grafana HTTP server attempt. - `bundle/logs/api_http_fixed.log` — fixed real Grafana HTTP server negative control. - `bundle/logs/library_vulnerable.log` and `bundle/logs/library_fixed.log` — supporting real-code authorization-client divergence. - `bundle/repro/runtime_manifest.json` — runtime evidence manifest written by the script. Key first-run excerpts: ```text Grafana is listening on 127.0.0.1:37329 HTTP_SURFACE method=GET path=/apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts LOW_PRIV_USER login=scoped-sa-reader basic_role=Viewer grant=serviceaccounts:read scoped_to_alpha_only HTTP_LIST_RESULT_JSON {"items":2,"names":["alpha-sa","beta-sa"],"path":"/apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts","status":200} BEHAVIOUR: VULNERABLE_HTTP - scoped low-priv user received unauthorized beta-sa over original HTTP serviceaccounts list endpoint ``` Fixed negative control from the same run: ```text Grafana is listening on 127.0.0.1:37211 HTTP_SURFACE method=GET path=/apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts HTTP_LIST_RESULT_JSON {"items":1,"names":["alpha-sa"],"path":"/apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts","status":200} BEHAVIOUR: FIXED_HTTP - scoped low-priv user list was filtered to authorized alpha-sa ``` Second-run confirmation: ```text Grafana is listening on 127.0.0.1:36869 HTTP_LIST_RESULT_JSON {"items":2,"names":["alpha-sa","beta-sa"],"path":"/apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts","status":200} BEHAVIOUR: VULNERABLE_HTTP - scoped low-priv user received unauthorized beta-sa over original HTTP serviceaccounts list endpoint ... Grafana is listening on 127.0.0.1:40001 HTTP_LIST_RESULT_JSON {"items":1,"names":["alpha-sa"],"path":"/apis/iam.grafana.app/v0alpha1/namespaces/default/serviceaccounts","status":200} BEHAVIOUR: FIXED_HTTP - scoped low-priv user list was filtered to authorized alpha-sa ``` Supporting library evidence: ```text vulnerable allowlist: "iam.grafana.app": map[string]interface{}{"users": nil, "teams": nil} IsCompatibleWithRBAC(iam.grafana.app, serviceaccounts) = false Check serviceaccounts: Allowed=true Compile serviceaccounts checker(alpha-sa) = true fixed allowlist: "iam.grafana.app": map[string]interface{}{"users": nil, "teams": nil, "serviceaccounts": nil} IsCompatibleWithRBAC(iam.grafana.app, serviceaccounts) = true Check serviceaccounts: Allowed=false Compile serviceaccounts checker(alpha-sa) = false ``` Environment details: Go 1.26.4, Grafana repository from the project cache, sqlite test database, Grafana test HTTP server, unified storage, `serviceaccounts.iam.grafana.app` dual-writer Mode5, feature flags `grafanaAPIServerWithExperimentalAPIs`, `kubernetesServiceAccountsApi`, and `kubernetesServiceAccountTokensApi`. ## Recommendations / Next Steps - Apply or retain fix commit `8891796ca1086cd234e1715ea71d8db0073cc160` so `serviceaccounts` is included in the `iam.grafana.app` RBAC allowlist. - Add regression tests for the product HTTP list path: a low-privileged user with `serviceaccounts:read` scoped to one service account must not receive other service accounts from `/apis/iam.grafana.app/v0alpha1/namespaces/{org}/serviceaccounts`. - Add allowlist coverage tests so future IAM resources cannot be silently omitted from `authzLimitedClient`. - Upgrade affected Grafana deployments to a build containing the fix. ## Additional Notes - The script is idempotent: it writes temporary Go test files, checks out only `access.go` at the vulnerable/fixed commits, and restores/removes modified files on exit. - The script was run twice consecutively and passed both times. - The proof uses Grafana's real HTTP server test environment and real authentication/permission setup. The library-level `authzLimitedClient` test is supporting evidence only; the primary proof is the product-facing HTTP endpoint divergence. - The implemented IAM service account API version in this repository is `v0alpha1`; no `v1` IAM service account API is served in `apps/iam/pkg/apis/iam_manifest.go`. The reproduction therefore uses the original available service account HTTP route for this checkout. ## Reproduction Details Reproduced: 2026-07-04T19:54:03.039Z Duration: 1900 seconds Tool calls: 429 Turns: Unknown Handoffs: 3 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00227 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00227&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00227/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - Source: https://github.com/spaceraccoon/vulnerability-spoiler-alert/issues/304 ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 16920 bytes) - bundle/repro/rca_report.md (analysis, 10560 bytes) - bundle/artifact_promotion_manifest.json (other, 5072 bytes) - bundle/repro/validation_verdict.json (other, 1132 bytes) - bundle/repro/runtime_manifest.json (other, 958 bytes) - bundle/logs/evidence.log (log, 5721 bytes) - bundle/logs/library_vulnerable.log (log, 542 bytes) - bundle/logs/library_fixed.log (log, 547 bytes) - bundle/logs/api_remote_attempt.log (log, 3256 bytes) - bundle/logs/reproduction_steps.log (log, 5720 bytes) - bundle/logs/reproduction_steps_second.log (log, 5721 bytes) - bundle/logs/api_http_vulnerable.log (log, 1623 bytes) - bundle/logs/api_http_fixed.log (log, 1570 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00227 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00227/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00227 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev