# 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 `<project_cache_dir>/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.