API security hardening — batch 2¶
Motivation¶
Continuation of the live black-box audit of the deployed control plane
(ca-elb-dashboard). Batch 2 closes the next tier of contract / observability
gaps on the public api-sidecar surface:
- #15 — error envelopes had no correlation id. A
401/404/422body carrieddetailonly, so a support request could not be tied back to theX-Request-IDalready present in the response header and server log. - #7 — unknown
/api/*paths could not be told apart from wrong-method calls. The catch-all answered a flat404for both, so a client hitting a real route with the wrong verb got noAllowhint. - #3 — the readiness probe leaked internal topology.
/api/health/readyreturned the full per-component map (which dependencies exist, the credential class name, truncated upstream error strings) to an anonymous caller. - #14 — pre-login browser errors were silently dropped.
/api/client-logrequired a bearer token, so exactly the MSAL redirect/login failures an operator most wants to see could never be reported. - #10 — the OpenAPI spec documented only
200/422. A reader could not tell that any authenticated route may answer401/403, that lookups may404, or that Azure-backed operations may surface a5xx.
User-facing change¶
- Error responses now echo
request_idin the JSON body when the request has a correlation id, matching the existingX-Request-IDheader (401,404,405,422,5xx). Additive — existingdetailis unchanged. - Unknown
/api/*paths now return404 {"detail":"unknown api route", ...}; a request to a known route with an unsupported method returns405 {"detail":"method not allowed", ...}plus anAllowheader listing the supported verbs. Neither case is ever forwarded to the SPA. /api/health/readykeeps its full per-component body by default. Behind the default-OFFSTRICT_READINESS_DETAILgate the body collapses tostatus/version/retryable/retry_after_secondsonly — enough for a load balancer / CI gate, nothing for a recon probe./api/client-logstays auth-required by default. Behind the default-OFFALLOW_ANONYMOUS_CLIENT_LOGgate it also accepts unauthenticated pre-login reports, logged withcaller=anonymous(size caps + sanitisation unchanged).- When
ENABLE_DOCS=true, the generated OpenAPI spec now declares a sharedErrorResponseschema and401/403/404/500responses on every operation. Documentation only — runtime behaviour is unchanged.
API / IaC diff summary¶
| Area | Change |
|---|---|
api/main.py |
http_exc_handler / validation_handler set request_id in the body via setdefault; new _document_common_error_responses() injects the shared ErrorResponse schema + common error responses into the custom OpenAPI. |
api/routes/frontend_proxy.py |
New _allowed_methods_for_known_path() (Starlette Match.PARTIAL) drives 405-vs-404 on unknown /api/* paths; both envelopes carry request_id. |
api/routes/health.py |
readiness() slims the body when STRICT_READINESS_DETAIL=true (default OFF). |
api/routes/client_log.py |
New _client_log_caller optional-auth dependency gated by ALLOW_ANONYMOUS_CLIENT_LOG (default OFF); anonymous reports logged as caller=anonymous. |
| IaC | None. The new gates default OFF and require no Container App env wiring to preserve current behaviour. |
Validation evidence¶
uv run pytest -q api/tests/test_api_hardening_batch2.py— 9 passed (request-id envelopes, 405+Allow, 404 unknown route, readiness gate ON/OFF, client-log auth ON/OFF, OpenAPI error responses).uv run pytest -q api/tests— 2404 passed, 3 skipped (full sweep, includingtest_persona_matrix.pyandtest_smoke.pyauth-required matrix which still expectsPOST /api/client-log→ 401 with the gate OFF).uv run ruff check api— All checks passed.
Hardening discipline (§12a)¶
- In scope:
auth(client-log opt-in),sanitise(envelope shaping) - RBAC change is single-PR safe (no role narrowed) — no role assignments touched
- Persona Matrix tests pass for owner / contributor / reader / dev_bypass
- Reader allowlist unchanged
- Capability Probe passes locally (no Azure surface touched; N/A code paths)
- RBAC removal preflight green locally — no
roleAssignmentsdiff in this change - New guards ship default-OFF behind
STRICT_READINESS_DETAIL/ALLOW_ANONYMOUS_CLIENT_LOGenv vars (#10/#7/#15are additive docs/contract changes, not auth tightening, so they ship always-on) - No
Depends(require_caller)added to an SSE event stream - Change note (this file) summarises persona impact
Persona impact: all four personas keep their current access. The two new gates
default OFF, so the deployed behaviour is byte-identical until an operator opts
in; ALLOW_ANONYMOUS_CLIENT_LOG=true is the only gate that relaxes auth and is
intentionally opt-in.