ADR 011 — Tenant Admin Self-Service Portal
Status: Accepted (Phase 5) Context date: 2026-07
Context
Tenant administrators (platform_role = "tenant_admin") previously had no self-service portal. Viewing their own tenant's rooms, users, recordings, or billing data required asking a platform operator to look it up on their behalf. This created an operational bottleneck for the platform team and a poor experience for tenant admins who needed routine visibility into their own resources.
The admin gateway and dashboard already existed for platform operators, backed by session-based authentication. Rather than building a separate tenant portal from scratch, the goal was to extend the existing admin surface with scoped, read-mostly access for tenant admins, reusing the Auth service's existing JWT and JWKS infrastructure.
Decision
1. Dual-mode authentication on the admin gateway
The admin gateway now accepts two authentication mechanisms:
admin_sessioncookie -- the existing session flow for platform operators. Unchanged.- JWT Bearer token -- tenant admins authenticate through the Auth service and receive a signed JWT. The gateway validates these tokens against the Auth service's JWKS endpoint.
Both paths produce an AdminContext (see below) that downstream handlers consume uniformly. The gateway inspects the incoming request for a session cookie first; if absent, it falls back to the Authorization: Bearer <token> header.
2. AdminContext replaces AdminUser
The old AdminUser context struct carried only the fields relevant to platform operators. A new AdminContext struct replaces it, carrying:
| Field | Type | Description |
|---|---|---|
UserID | UUID | Authenticated user's ID |
Email | string | Authenticated user's email |
Role | string | platform_admin or tenant_admin |
TenantID | UUID | The tenant the user belongs to (always set for tenant admins; may be empty for platform admins) |
Mode | string | session or jwt, indicating which auth path was used |
All handler signatures that previously accepted AdminUser now accept AdminContext. This is a breaking internal change but affects no external API contracts.
3. Tenant scope enforcement
Every handler enforces tenant boundaries for tenant admin requests:
- List operations -- the handler forces a
tenant_id = <ctx.TenantID>filter, regardless of any tenant filter the caller supplies. A tenant admin cannot list resources across tenants. - Get operations -- after fetching the resource, the handler verifies that the resource's tenant matches
ctx.TenantID. A mismatch returns 403. - Destructive and admin-only operations -- a
RequirePlatformOperatormiddleware gates these routes entirely. Tenant admins receive 403 before the handler executes.
Routes restricted to platform operators:
| Method | Route | Reason |
|---|---|---|
| POST | /tenants | Tenant provisioning |
| POST | /tenants/:id/suspend | Tenant lifecycle |
| POST | /tenants/:id/activate | Tenant lifecycle |
| POST | /users/:id/suspend | User moderation |
| POST | /users/:id/activate | User moderation |
| DELETE | /recordings/:id | Destructive data operation |
| GET | /health | Infrastructure visibility |
| GET | /audit | Cross-tenant audit log access |
4. Dashboard dual-login and scoped UI
The dashboard login page presents two tabs:
- Platform Admin -- the existing session-based login flow against the admin gateway.
- Tenant Admin -- authenticates against the Auth service's token endpoint. On success, the returned JWT is stored in a
tenant_tokenHttpOnly cookie (Secure, SameSite=Strict). Subsequent dashboard API calls include this cookie, which the Next.js API routes forward as a Bearer token to the gateway.
The dashboard sidebar adapts based on the authenticated role:
- Hidden for tenant admins: Infrastructure and Audit Log sections.
- Visible for tenant admins: Rooms, Users, Recordings, and Billing, all scoped to their tenant.
5. No new database tables
This feature introduces no new database schema. It reuses:
- The Auth service's existing JWKS endpoint for JWT validation.
- The existing
platform_rolefield on users to distinguishtenant_adminfromplatform_admin. - Existing tenant-scoped queries across all resource tables (rooms, users, recordings, billing).
The only net-new persistence is the tenant_token HttpOnly cookie in the browser, which is stateless from the server's perspective.
Consequences
- Reduced operational load -- tenant admins no longer need to contact platform operators for routine data lookups. This removes the most common support request category.
- Minimal blast radius -- the change is additive. Platform operator flows are untouched. The
RequirePlatformOperatormiddleware is a whitelist on destructive routes rather than a blacklist on tenant admin routes, so new routes default to requiring platform access until explicitly opened. - Single dashboard codebase -- maintaining one dashboard with role-based visibility is simpler than maintaining two separate frontends, at the cost of conditional rendering logic in the sidebar and route guards.
- JWT expiry and refresh -- tenant admin sessions are bounded by the JWT's
expclaim. If the Auth service's token TTL is short, tenant admins may be logged out more frequently than platform operators whose server-side sessions have independent TTL management. A token refresh flow may be needed if this proves disruptive. - Audit trail gap -- tenant admin actions currently flow through the same gateway audit middleware, so they are logged. However, the audit log itself is not visible to tenant admins. If tenant admins need to audit their own actions, a scoped audit view is a future consideration.
Verification
- Gateway integration tests cover both auth paths and confirm that tenant admin requests are scoped correctly (list returns only own-tenant resources, cross-tenant get returns 403, platform-only routes return 403).
- Dashboard E2E tests confirm that the tenant admin login flow stores the
tenant_tokencookie and that restricted sidebar items are not rendered. - Manual verification that a tenant admin JWT issued by the Auth service is accepted by the gateway and that an expired or tampered token is rejected.