OpenAPI execution RBAC gate (opt-in, default-OFF)¶
Motivation¶
The OpenAPI menu (/docs) and curl calls go through
/api/aks/openapi/proxy, which auto-injects the admin X-ELB-API-Token and is
protected by require_caller (tenant membership only — no per-caller Azure
RBAC). Because OBO flows are forbidden by charter §12, any authenticated tenant
member — even a subscription Reader — could drive state-changing calls
(e.g. POST /v1/jobs) through the admin token. The forensic audit trail
(shipped earlier this session) records who drove each mutating call, but does
not block it. This change adds an opt-in gate that actually restricts
execution to callers with a write role on the target resource group.
User-facing change¶
- Default (env unset): no behaviour change. Any tenant member can still drive OpenAPI execution exactly as before.
- When
ENFORCE_OPENAPI_EXEC_RBAC=true: state-changing proxy verbs (POST/PUT/PATCH/DELETE) are forwarded only if the caller holds a write role (Contributor / Owner / AKS write) on the target resource group. - No write role →
403 openapi_exec_forbidden(includes the caller's matched roles for the tooltip). - RBAC lookup indeterminate →
403 openapi_exec_rbac_indeterminate(fail-closed). Requires the api MI to haveMicrosoft.Authorization/roleAssignments/readat the subscription scope. - Read-only
GET/HEAD/OPTIONSand the dev-bypass identity are never gated.
API / IaC diff summary¶
- New service
api/services/openapi/exec_gate.py:evaluate_openapi_exec_gate,ExecGateDecision,is_exec_rbac_enforced. Reusescompute_caller_permissionsbut fails closed ondegraded(the UX helper fails open). - Route
api/routes/aks/openapi.py(aks_openapi_proxy): runs the gate off the event loop right after credential/subscription resolution, denying before the upstream is resolved or the admin token injected. Audit-trail comment updated to note the gate now precedes it. - Infra
infra/modules/containerAppControl.bicep: newENFORCE_OPENAPI_EXEC_RBACenv defaulting to'false'(charter §12a Rule 4). - Docs
docs/operate/openapi-exec-rbac-gate.md(+ mkdocs nav entry): operator runbook with the decision table, MI prerequisite, and planned default-flip target (2026-07, after a dogfood cycle with the gate forced ON and a green Persona Matrix).
Validation evidence¶
uv run pytest -q api/tests/test_openapi_exec_gate.py→ 7 passed (positive ON path + legacy OFF path per charter §12a Rule 4: enforcement disabled, read-only never gated, dev-bypass, write-role allow, no-write deny, degraded fail-closed, truthy-token parsing).uv run ruff checkclean on the new/changed files.uv run python scripts/docs/check_frontmatter.py→ OK (51 navigated pages).- Persona Matrix unaffected: the gate is a runtime-conditional default-OFF
guard, not a route-gating change, so
require_callerand the persona whitelist are untouched.