ADR 0021 — Split the Tenant Dashboard and the Platform-Operator Console into Two Apps
Status: Accepted Context date: 2026-07-26
Context
Since ADR 0020 the single Next.js app at admin/dashboard has served two very different audiences from one codebase and one deployment:
- Tenant admins — customers managing their own organization (members, rooms, recordings, billing, API keys) on the public
app.host. - Platform operators — EthioConnect staff running the platform (tenant directory, suspend/activate, infrastructure health, audit log) on the internal
admin.host.
The single app carried the split entirely in conditionals: useSession() exposed isPlatformOperator / isTenantAdmin, and the sidebar, middleware, API proxy, and roughly half the pages branched on role — relabeling headings ("Users" vs "Members"), hiding columns and buttons, host-gating /ops/* in middleware, and forwarding both credential types (admin_session session cookie and tenant_token JWT) through one catch-all proxy. Keeping the operator surface unreachable on the public origin even required a snippet-based ingress (ethioconnect-app-ops-block) that ingress-nginx ≥ v1.9 silently ignores unless allow-snippet-annotations is enabled.
The practical cost: every change to the tenant dashboard — the surface under active development — had to be reasoned about twice, once per role, and could silently break the operator view (and vice versa). The two surfaces also have different security postures (public self-serve vs internal-only) but shared one build artifact.
Decision
Split into two separate Next.js apps, both talking to the unchanged admin gateway:
| Tenant dashboard | Operator console | |
|---|---|---|
| Directory | admin/dashboard (evolved in place) | admin/ops-dashboard (new) |
| Host | app.ethioconnect.example.com | admin.ethioconnect.example.com |
| K8s service / image | admin-dashboard (unchanged) | ops-dashboard |
| Dev port | 3300 | 3400 |
| Credential | tenant_token (JWT cookie) | admin_session (session cookie) |
| Routes | / overview, /users (Members), /rooms, /recordings, /billing, /webhooks, /api-keys, /settings, /login, /signup, /onboarding | / → /tenants, /tenants (+detail), /users (+suspend), /recordings (+delete), /infrastructure, /audit, /login |
Key choices:
admin/dashboardstays and becomes the tenant app. Most pages and all deploy wiring (compose service, k8s manifests, helm chart, ingress backend) belong to the tenant surface; renaming the directory would touch ~15 deploy files for zero functional gain. Operator-only pages weregit mved out so history follows them.- Both apps keep
basePath: '/dashboard'. Theadmin.ingress already proxies/dashboard/*, probes hit/dashboard, and the gateway hardcodes cookiePath=/dashboard/api/auth— so the split needs zero Go changes. Operator routes flatten from/dashboard/ops/*to the ops app's own/dashboard/*(login moves to/dashboard/loginon the admin host; the ingressapp-rootannotation was updated accordingly). - Per-app credential forwarding. Each app's
/api/[...path]proxy forwards only its own credential to the gateway and returns 404 for the other surface's login route (tenant app 404s/api/auth/login; ops app 404s/api/auth/tenant-loginand/api/auth/signup). This holds in local dev and on any misconfigured ingress, so the fragile snippet-basedethioconnect-app-ops-blockIngress was deleted — the operator surface simply does not exist in the tenant app anymore. Host-only cookies (ADR 0020) remain the other isolation layer. - Small shared files are duplicated, not extracted.
api.ts,useSession.ts,usePaginatedList.ts,ErrorNotice,Pagination(~300 lines) are copied into the ops app, following the existing precedent ofportal/owning its own API client. Divergence per surface (different cookie names, different 401 targets, no role union types) is exactly the entanglement the split removes; npm workspaces for this would be tooling overhead the repo doesn't otherwise need. - Minimal operator surface. The ops app received only the pages operators need for actions that exist nowhere else: tenant CRUD + suspend/activate, user suspend/activate, recording delete, infrastructure health, audit log. Read-only views (rooms, billing, webhooks) were deliberately left out and can be added when a concrete operator need appears.
Middleware simplified on both sides to a single-cookie presence check — the tenant app lost the host-gating of /ops/* (nothing to gate), and the gateway remains the real enforcer (RequirePlatformOperator, EnforceTenantScope).
Consequences
Positive
- Tenant-dashboard changes can no longer break the operator console, and vice versa; each app is a smaller, single-audience codebase with zero role conditionals.
- The operator login is structurally unreachable from the public origin — no reliance on ingress snippet annotations being enabled.
- Each surface can evolve its security posture independently (the internal console can later sit behind a VPN/IAP without touching the tenant app).
- Operator console deploys with 1 replica (low-traffic internal tool) instead of inflating the public tier's footprint.
Negative / accepted
- ~300 lines of shared UI code are now duplicated and may drift; accepted per the
portal/precedent, revisit with npm workspaces only if a third web app appears. - Two builds, two images, two deployments (compose service
ops-dashboardon :3001, k8sdeploy/k8s/base/ops-dashboard/, helm chartops-dashboard, extra release-workflow step). - Operator bookmarks to
/dashboard/ops/loginbreak (the admin host'sapp-rootnow redirects/to/dashboard/login).
Wiring changed: admin/docker-compose.override.yml (new service), deploy/k8s/base/ops-dashboard/ + kustomization.yaml, deploy/k8s/base/ingress.yaml (admin host → ops-dashboard:3000, ops-block Ingress removed), deploy/k8s/base/network-policies/allow-ingress-to-web.yaml (ops-dashboard added to ingress/egress rules), deploy/helm/charts/ops-dashboard/ + umbrella chart, .github/workflows/release.yml (ops image step; dashboard build contexts corrected to the app directories).