ADR-0081: Super-admin tenant impersonation over SSE via validated query parameter
- Status: Accepted
- Date: 2026-06-30
- Deciders: Adrian (Soft4U), Claude
- Related: ADR-0023 (RLS multi-tenant isolation), ADR-0050/0051 (RLS role-flip + tenant-scoped sessions), ADR-0074 (RBAC enforcement)
Context
Super-admins operate across tenants by impersonation: the frontend stores the
chosen tenant in localStorage["trust_relay_active_tenant"] and the axios
interceptor forwards it as the X-Tenant-Id request header. The backend
_resolve_tenant (app/api/deps/tenant.py) honours that header for super-admins
only, after validating it is a UUID, and returns it as the effective tenant (which
then sets the RLS app.current_tenant GUC). Every per-case endpoint
(get_case, evidence, bundle detail, recalc after ADR-0081's predecessor #148)
resolves tenant this way and works under impersonation.
The gap: Server-Sent Events. The case detail opens an EventSource to
GET /api/cases/{wf}/agent-progress/stream (live pipeline progress). The browser's
native EventSource cannot set request headers — which is exactly why that
endpoint already authenticates via a ?token= query parameter instead of the
Authorization header. The same limitation means the X-Tenant-Id header never
reaches SSE endpoints, so _resolve_tenant falls back to the super-admin's native
tenant. For a super-admin impersonating another tenant, the case lives in the
impersonated tenant → RLS hides it → the stream 404s (observed live: Demo-tenant
super-admin viewing a DigiTeal case).
Native-tenant users (e.g. a DigiTeal officer) are unaffected — their token already carries the correct tenant. This only bites cross-tenant impersonation over SSE.
Decision
Allow the impersonated tenant to be supplied as a tenant_id query parameter, but
only on SSE endpoints and under the same trust gate as the header:
_resolve_tenant(request, user, *, allow_query_override=False)gains an optional flag. WhenTrueand the user issuper_adminand noX-Tenant-Idheader is present, it readsrequest.query_params.get("tenant_id"), validates it is a UUID, and returns it. Invalid → ignored (logs a warning, falls back to native tenant).get_current_tenant_ssecalls_resolve_tenant(..., allow_query_override=True).get_current_tenant(all non-SSE endpoints) calls it with the defaultFalse— header remains the only impersonation channel off-SSE.- The frontend appends
&tenant_id=<active>to SSE URLs (AgentPipelineView,EntityGraph) whenever an active tenant is selected, via a new exportedgetActiveTenant()helper — mirroring the existing?token=pattern.
Security analysis
- Same authority as the header. The override is honoured only for
super_adminand only after UUID validation — identical to the existing header path. A non-super-admin'stenant_idquery param is silently ignored. - Scoped to SSE.
allow_query_overridedefaults toFalse, so the query-param channel exists only onget_current_tenant_sse. Regular REST endpoints cannot be impersonated via URL — they stay header-only. This minimises the loggable-URL surface to the few SSE routes that genuinely cannot use a header. - Not a privilege escalation. Impersonation already exists for super-admins via the
header; this adds a transport, not a new capability. RLS at the database
(ADR-0023/0050) remains the enforcement boundary — the resolved tenant only sets
app.current_tenant; it does not grantrls_bypass. A super-admin can reach a tenant's rows via impersonation exactly as the product intends. - Log exposure is bounded and lower than status quo. Query params appear in server
logs / browser history / proxies. The value here is a tenant UUID — not a secret
and not PII. The SSE URL already carries the far more sensitive JWT in
?token=; adding a tenant UUID is strictly less sensitive than what is already there. - No new sink. The resolved tenant flows only into the existing RLS GUC path; no new query, table, or external call is introduced.
Alternatives considered
- Catch-all SSE + header — impossible;
EventSourcecannot send headers. - Cookie-carried active tenant — adds CSRF surface and cross-port cookie issues (the same class that breaks Keycloak login on HTTP localhost); rejected.
- Per-tenant subdomains — infra-heavy, out of scope for the PoC.
- Leave SSE broken under impersonation — the path we shipped first (native-tenant
login
jan@demo.iosidesteps it). Acceptable as a workaround but leaves a real super-admin capability gap; this ADR closes it.
Consequences
- Live pipeline progress (and any future SSE endpoint) works for impersonating super-admins.
- A tenant UUID appears in SSE request URLs when impersonating (documented, accepted).
- Future SSE endpoints get cross-tenant support for free by depending on
get_current_tenant_sse.
Follow-up: closed (2026-06-30)
The adversarial security review flagged that impersonation (header OR query) was only
logger-recorded, not written to the immutable audit_events — a compliance-
traceability gap. Now closed: AuditService.record_cross_tenant_access writes a
cross_tenant_access audit row when a super-admin's effective tenant differs from their
native tenant, invoked from the two case-access paths — get_case (header) and the
agent-progress/stream SSE endpoint (query). The row is case-scoped (audit_events.case_id
is NOT NULL), owns the impersonated tenant, and carries {actor_id, actor_email, native_tenant, impersonated_tenant, via}. Deduped per (actor, case) per 30-min window so
it never floods the 5-year table, and non-fatal so it never breaks the audited request.