OpenAPI execution RBAC gate¶
Why this exists¶
The dashboard's OpenAPI menu (/docs) and any curl against
/api/aks/openapi/proxy are served by a proxy that auto-injects the admin
X-ELB-API-Token and forwards the call to the elb-openapi pod. The proxy is
protected by require_caller, which validates tenant membership only — it
does not perform a per-caller Azure RBAC
check. Because On-Behalf-Of token flows are forbidden by charter §12 (all
Azure work runs under the shared managed identity), the historical behaviour is
that any authenticated tenant member — even a subscription Reader — can drive
state-changing OpenAPI calls (for example POST /v1/jobs to submit a BLAST
job) through the admin token.
Two mitigations ship for this:
- Forensic audit trail (always on). Every state-changing proxy call
(
POST/PUT/PATCH/DELETE) writes a token-free audit row recording the caller OID, tenant, method, and target path. Seeapi/services/openapi/proxy_audit.py. - Opt-in RBAC gate (this document). When enabled, the proxy only forwards a mutating call if the caller actually holds a write role on the target resource group.
What the gate does¶
api/services/openapi/exec_gate.py (evaluate_openapi_exec_gate) decides, in
order:
| Step | Condition | Outcome |
|---|---|---|
| 1 | ENFORCE_OPENAPI_EXEC_RBAC off (default) |
allow — legacy behaviour |
| 2 | Read-only method (GET/HEAD/OPTIONS) |
allow — never gated |
| 3 | Dev-bypass identity (local debug) | allow |
| 4 | RBAC lookup indeterminate (degraded) |
deny 403 openapi_exec_rbac_indeterminate (fail-closed) |
| 5 | Caller holds a write role at the RG scope | allow |
| 6 | Otherwise | deny 403 openapi_exec_forbidden |
"Write role" reuses the tested compute_caller_permissions definition:
Owner, Contributor, Azure Kubernetes Service Contributor, AKS RBAC Admin /
Cluster Admin / Writer.
The RBAC lookup is cached 60 s per (caller OID, scope), so the gate adds at
most one ARM roleAssignments.list_for_subscription call per caller per scope
per minute.
Fail-closed, not fail-open¶
The dashboard's permission helper (compute_caller_permissions) degrades
open on enumeration failure — that is a deliberate UX affordance so a
transient ARM hiccup never greys out a button. The execution gate inverts
this: when enforcement is on and the lookup is indeterminate, it denies.
Otherwise a managed identity that lacks
Microsoft.Authorization/roleAssignments/read would silently disable the gate.
How to enable it¶
The gate ships default-OFF per charter §12a Rule 4. Set the container app
env var to a truthy value (1 / true / yes / on):
Re-run scripts/dev/postprovision.sh (or azd provision) to roll the new
revision.
Prerequisite — MI role-read permission¶
When enabled, the shared api managed identity must be able to read the
caller's role assignments. Grant the
Reader
built-in (or any role that includes
Microsoft.Authorization/roleAssignments/read) to id-elb-dashboard-* at the
subscription scope. Without it, every state-changing call fails closed with
openapi_exec_rbac_indeterminate.
Planned default flip¶
This guard stays default-OFF until at least one full release cycle of
dogfood with the gate forced ON and a green
api/tests/test_persona_matrix.py
run. Earliest flip target: 2026-07 (revisit after the audit-trail data
confirms which personas drive /v1/ mutations in practice). The flip is its
own PR that changes the bicep default and removes the OFF-path note here.