ADR-004 · Separate control plane from tenant app
Status: Accepted
Context
Two very different audiences use the web product: NexSchool operators, who manage all schools (create tenants, assign plans, seed schools, impersonate for support), and school staff, who manage one school. Early on, some operator capabilities — onboarding, login-as-tenant — lived partly inside the tenant-facing app. That put cross-tenant and structural power one bug (or one curious school admin) away from exposure.
Decision
Split the web surface into two apps with two API surfaces:
- panel (
super-admin-panel) — the control plane. Operator-only. Talks to/api/platform/*, which bypasses tenant resolution and requires@platform_admin_required. Owns tenant lifecycle, plans, onboarding/seeding, audit, and impersonation. - admin-web (
school-admin-panel) — tenant-only. Talks to tenant-scoped/api/*with tenant-user JWT + RBAC.
Reinforcing moves:
- The structured school-setup flow is super-admin-only — the tenant Admin role lost
school_setup.*(migration064). - Impersonation lives only in the panel: god-login, plus a one-click “Open admin-web” backed by a Redis single-use, ~90s-TTL, hashed, fail-closed login code.
Consequences
Positive
- Operator power is concentrated in one auditable place; the tenant app cannot reach cross-tenant controls.
- Clear security boundary: platform auth (
@platform_admin_required) never mixes with tenant RBAC. - A single home for white-glove pilot onboarding.
Negative / cost
- Two frontends to build and maintain.
- Impersonation must be carefully secured (hence the fail-closed one-time-code design) — it is inherently powerful.
- A shared dependency: links depend on
ADMIN_WEB_BASE_URLbeing set in prod, or they point atlocalhost.
Alternatives considered
- One app, role-gated — fewer moving parts, but operator features sit inside the tenant app’s blast radius; rejected on security grounds.