Decisions (ADRs)ADR-004 · Control-plane split

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.* (migration 064).
  • 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_URL being set in prod, or they point at localhost.

Alternatives considered

  • One app, role-gated — fewer moving parts, but operator features sit inside the tenant app’s blast radius; rejected on security grounds.