Skip to main content

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:

  1. _resolve_tenant(request, user, *, allow_query_override=False) gains an optional flag. When True and the user is super_admin and no X-Tenant-Id header is present, it reads request.query_params.get("tenant_id"), validates it is a UUID, and returns it. Invalid → ignored (logs a warning, falls back to native tenant).
  2. get_current_tenant_sse calls _resolve_tenant(..., allow_query_override=True). get_current_tenant (all non-SSE endpoints) calls it with the default Falseheader remains the only impersonation channel off-SSE.
  3. The frontend appends &tenant_id=<active> to SSE URLs (AgentPipelineView, EntityGraph) whenever an active tenant is selected, via a new exported getActiveTenant() helper — mirroring the existing ?token= pattern.

Security analysis

  • Same authority as the header. The override is honoured only for super_admin and only after UUID validation — identical to the existing header path. A non-super-admin's tenant_id query param is silently ignored.
  • Scoped to SSE. allow_query_override defaults to False, so the query-param channel exists only on get_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 grant rls_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; EventSource cannot 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.io sidesteps 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.