Skip to content
↑↓Navigate↵SelectescClose

0024 - Platform-administrator console as a separate trust boundary

Admin console decision separating administrator sessions from tenant console sessions

  • Status: Accepted
  • Date: 2026-07-30
  • Deciders: nexus core team
  • Tags: auth, admin, console, nextjs, bff, security

The deployment administrator surface (user lifecycle, workspace creation and inspection, workspace membership administration, operator-funded wallet grants) is served by admin-control-plane and requires a credential with deployment-tenant-wide authority. The tenant console at apps/console/ in the source repository authenticates tenant users against the auth service and holds their access token server-side per 0002 - Next.js App Router for the tenant console.

Reusing that same session for administrator work would mean one cookie, one token audience, and one refresh chain covering both a tenant member and a platform administrator. A tenant token would then be presentable to admin-control-plane, and an escalation bug in tenant-facing code would reach administrator routes. Administrator authority also warrants a shorter credential lifetime than a tenant session, and the two lifetimes cannot differ while they share one token type.

  • A tenant session must not be usable against the administrator surface, and the reverse, even when both are signed by the same deployment.
  • Administrator credential lifetime must be independently configurable and shorter by default.
  • Administrator authority must be revocable without waiting for a token to expire.
  • Access tokens stay server-side, as they do for the tenant console.
  • A rejection on the administrator surface must not disclose whether an email exists, a password was correct, or a subject holds an administrator assignment.
  • A separate Next.js application with its own cookies and its own token audience — chosen.
  • An admin route group inside apps/console sharing the tenant session — rejected: one cookie and one audience mean a tenant token is accepted on administrator routes, the two lifetimes cannot diverge, and every tenant-facing authorization bug becomes an administrator-surface bug.
  • A separate signing key (or asymmetric keys) per audience — rejected: it removes the shared-secret blast radius but adds a second secret to distribute, rotate, and verify across auth, admin-control-plane, and the administrator console. The audience split plus the per-call admins lookup carries the authorization boundary; the key split only narrows the consequence of an already-total secret compromise.
  • A separate identity store for administrators — rejected: administrators are existing users with an assignment row, so a second store would duplicate password hashing, account state, and audit plumbing.

Chosen option: a separate administrator console application with a distinct administrator session in the auth service.

The administrator console lives at apps/admin-console/ in the source repository (package @nexus/admin-console, port 14432) and follows the same server-side BFF pattern as the tenant console: the browser never receives a token. Its routes are POST /api/session/sign-in, POST /api/session/refresh, POST /api/session/sign-out, GET /api/session/me, GET /api/platform/readiness, and GET /healthz. Protected surfaces sit under an (admin) route group whose server layout calls getCurrentAdmin() and redirects to /sign-in when no valid administrator session is present. Session cookies are nexus_admin_access and nexus_admin_refresh — names distinct from the tenant console’s, since a port does not scope a cookie — both httpOnly and sameSite=strict, with secure gated by NEXUS_SECURE_COOKIES, expiring at the token’s own expiry. The sign-in form renders one generic failure message for every rejection.

Three separations distinguish an administrator session from a tenant session in the services/auth/ source:

  1. Audience. JwtCodec holds a tenant audience (nexus-console) and an administrator audience (nexus-admin-console), both HS256 over AUTH_JWT_SECRET. verify() accepts only the tenant audience and verify_admin() only the administrator audience, and both claim structs carry #[serde(deny_unknown_fields)]. A tenant access token therefore fails VerifyAdminToken, and an administrator token fails VerifyToken.
  2. Session rows. The 0004_session_kind.sql migration adds a CHECK-constrained sessions.kind ('user' | 'admin') plus the index sessions_refresh_lookup_idx (tenant_id, refresh_hash, kind). Refresh rotation and revocation match on kind, so a refresh token minted for one credential cannot be redeemed on the other’s path.
  3. Lifetime. Administrator access tokens default to 30 minutes and refresh tokens to 12 hours, set by AUTH_ADMIN_ACCESS_TTL_SECS and AUTH_ADMIN_REFRESH_TTL_SECS. Tenant sessions remain 1 day and 30 days. Both session kinds expire after one hour without an authenticated request by default (AUTH_SESSION_IDLE_TIMEOUT_SECS); setting it to 0 disables inactivity expiry.

The administrator REST surface is POST /api/auth/admin/sign-in, /api/auth/admin/refresh, /api/auth/admin/sign-out, and /api/auth/admin/verify-token. Sign-in authenticates the password and then requires an active row in the admins table for the deployment tenant; every failure returns 401 with no distinguishing detail. admin_refresh re-checks the administrator assignment and the user’s active state on every rotation, and VerifyAdminToken re-checks the admins table on every call, so revoking an assignment or disabling an account ends access at the next verification rather than at token expiry. nexus-admin in admin-cli/ uses the same routes, refreshing through /api/auth/admin/refresh when its stored access token has expired but its refresh token has not.

  • A stolen tenant-console token is inert against the administrator surface and a stolen administrator token is inert against tenant routes: two independent verification paths, two cookie pairs, two refresh chains.
  • A compromised AUTH_JWT_SECRET can mint either audience. The audience split is an authorization boundary between two honest verifiers, not a defense against holding the signing key. This is the accepted cost of one shared secret across both audiences; treat AUTH_JWT_SECRET disclosure as full compromise of both surfaces and rotate it.
  • Administrator assignment has no in-product provisioning path. No code path in this repository writes a row into the admins table. Creating the first platform administrator requires a direct SQL insert into nexus_auth.admins, and the administrator console neither grants nor revokes assignments.
  • Administrators re-authenticate after one hour without an authenticated request by default; their refresh tokens remain valid for 12 hours after issuance.
  • The image set gains a second Node service alongside console, and the pnpm workspace gains a second Next.js application to build, lint, and keep on a compatible dependency graph.
  • The administrator surface is now reachable two ways with the same credential: this console and nexus-admin. Both authenticate through the same routes, so an authorization change in auth applies to both without per-client work.
  • Administrator session lifecycle is auditable on its own event names: admin.session.signed_in, admin.session.refreshed, and admin.session.denied (recorded when a correct password belongs to a subject holding no administrator assignment). No token material is recorded.