feat(auth): bootstrap first admin from BOOTSTRAP_ADMIN_EMAIL (BUNYIP-290) #349

Merged
longjacksonle merged 1 commit from feat/BUNYIP-290-bootstrap-admin-email into main 2026-07-08 05:04:23 +02:00

Implements BUNYIP-290. Replaces the interactive first-admin setup wizard with a declarative BOOTSTRAP_ADMIN_EMAIL env-var bootstrap.

Behavior

The site comes up fully usable against a freshly wiped database with no setup gate and no /setup page. The first admin is created automatically when the user whose email matches BOOTSTRAP_ADMIN_EMAIL authenticates (sign-up or sign-in) while zero admins exist. Once any admin exists the bootstrap path is inert, and the existing admin-invite and role-management flows govern all further admin changes. The gate is "zero admins exist", so it self-heals: if every admin is later removed, the bootstrap email can re-establish the first admin on its next authentication (intended lockout recovery, not a second bootstrap of a live system).

Changes

  • Config: Config.bootstrap_admin_email: Option<String> read from BOOTSTRAP_ADMIN_EMAIL in from_env_inner, trimmed + lowercased so it compares equal to stored/normalized emails; unset or empty = None. Startup logs whether a bootstrap admin is configured (boolean only, never the address per BUNYIP-265).
  • Promotion: AuthService::ensure_bootstrap_admin is called from register (before the audit log) and from login (after the password check, before any 2FA branch or token mint), so the issued JWT carries role = "admin" with no second login. The decision is a pure, DB-free bootstrap_promotion_needed predicate; the caller adds a cheap pre-filter so the admin-count query runs only inside the actual bootstrap window. Promotion uses the idempotent update_role, so concurrent bootstrap authentications still converge on a single admin.
  • Wizard removed: POST /v1/auth/setup + setup_admin (bunyip-api); /setup route, setup_get/setup_post, SetupForm, setup_card, and the setup POST client (bunyip-web). GET /v1/auth/setup/status stays as a feature-flags probe (email_enabled / stripe_enabled) but drops the now-meaningless setup_required field from the response, SetupStatus, and the three web consumers.
  • Ops: BOOTSTRAP_ADMIN_EMAIL documented in .env.example and set on the api service in compose.dev.yml and compose.yml. SETUP_DEFAULT_ADMIN and the e2e bootstrap binary are unchanged.

Tests

Six new unit tests cover the five promotion scenarios from the issue (promote on match with zero admins, case-insensitive match, non-bootstrap email stays subscriber, inert once an admin exists, no re-promote of an existing admin, and unset leaves the site admin-less). just check equivalents (fmt + clippy -D warnings + build + full workspace test suite) are green.

No sqlx::query! macros were touched, so no .sqlx/ regeneration is needed. No e2e specs reference the removed /setup flow.

🤖 Generated with Claude Code

Implements BUNYIP-290. Replaces the interactive first-admin setup wizard with a declarative `BOOTSTRAP_ADMIN_EMAIL` env-var bootstrap. ## Behavior The site comes up fully usable against a freshly wiped database with no setup gate and no `/setup` page. The first admin is created automatically when the user whose email matches `BOOTSTRAP_ADMIN_EMAIL` authenticates (sign-up or sign-in) while zero admins exist. Once any admin exists the bootstrap path is inert, and the existing admin-invite and role-management flows govern all further admin changes. The gate is "zero admins exist", so it self-heals: if every admin is later removed, the bootstrap email can re-establish the first admin on its next authentication (intended lockout recovery, not a second bootstrap of a live system). ## Changes - **Config**: `Config.bootstrap_admin_email: Option<String>` read from `BOOTSTRAP_ADMIN_EMAIL` in `from_env_inner`, trimmed + lowercased so it compares equal to stored/normalized emails; unset or empty = `None`. Startup logs whether a bootstrap admin is configured (boolean only, never the address per BUNYIP-265). - **Promotion**: `AuthService::ensure_bootstrap_admin` is called from `register` (before the audit log) and from `login` (after the password check, before any 2FA branch or token mint), so the issued JWT carries `role = "admin"` with no second login. The decision is a pure, DB-free `bootstrap_promotion_needed` predicate; the caller adds a cheap pre-filter so the admin-count query runs only inside the actual bootstrap window. Promotion uses the idempotent `update_role`, so concurrent bootstrap authentications still converge on a single admin. - **Wizard removed**: `POST /v1/auth/setup` + `setup_admin` (bunyip-api); `/setup` route, `setup_get`/`setup_post`, `SetupForm`, `setup_card`, and the `setup` POST client (bunyip-web). `GET /v1/auth/setup/status` stays as a feature-flags probe (`email_enabled` / `stripe_enabled`) but drops the now-meaningless `setup_required` field from the response, `SetupStatus`, and the three web consumers. - **Ops**: `BOOTSTRAP_ADMIN_EMAIL` documented in `.env.example` and set on the api service in `compose.dev.yml` and `compose.yml`. `SETUP_DEFAULT_ADMIN` and the e2e bootstrap binary are unchanged. ## Tests Six new unit tests cover the five promotion scenarios from the issue (promote on match with zero admins, case-insensitive match, non-bootstrap email stays subscriber, inert once an admin exists, no re-promote of an existing admin, and unset leaves the site admin-less). `just check` equivalents (fmt + clippy `-D warnings` + build + full workspace test suite) are green. No `sqlx::query!` macros were touched, so no `.sqlx/` regeneration is needed. No e2e specs reference the removed `/setup` flow. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): bootstrap first admin from BOOTSTRAP_ADMIN_EMAIL (BUNYIP-290)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 30s
Check / fmt + clippy + build + tests (pull_request) Successful in 20m6s
Create release / Create release from merged PR (pull_request) Has been skipped
4ff90cde89
Replace the interactive first-admin setup wizard with a declarative env-var bootstrap. The site now comes up fully usable against a freshly wiped database with no setup gate; the first admin is created automatically when the user whose email matches BOOTSTRAP_ADMIN_EMAIL authenticates (sign-up or sign-in) while zero admins exist. Once any admin exists the bootstrap path is inert and the existing admin-invite and role-management flows govern all further admin changes. The gate is "zero admins exist", so it self-heals: if every admin is later removed, the bootstrap email can re-establish the first admin on its next authentication.

Config: add Config.bootstrap_admin_email: Option<String>, read from BOOTSTRAP_ADMIN_EMAIL in from_env_inner (trimmed + lowercased so it compares equal to stored/normalized emails; unset or empty = None). Startup logs whether a bootstrap admin is configured (boolean only, never the address).

Promotion: AuthService gains ensure_bootstrap_admin, called from register (before the audit log) and from login (after the password check, before any 2FA branch or token mint) so the issued JWT carries role = "admin" with no second login. The decision is a pure, DB-free bootstrap_promotion_needed predicate (unit-tested for all five scenarios); the caller adds a cheap pre-filter so the admin-count query runs only inside the actual bootstrap window. Promotion via the idempotent update_role, so concurrent bootstrap authentications still converge on a single admin.

Remove the wizard: delete POST /v1/auth/setup and setup_admin (bunyip-api), and the /setup route, setup_get/setup_post, SetupForm, setup_card, and the setup POST client (bunyip-web). GET /v1/auth/setup/status stays as a feature-flags probe (email_enabled / stripe_enabled) but drops the now-meaningless setup_required field from the response, SetupStatus, and the three web consumers.

Ops: document BOOTSTRAP_ADMIN_EMAIL in .env.example and set it on the api service in compose.dev.yml and compose.yml. SETUP_DEFAULT_ADMIN and the e2e bootstrap binary are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015G3EqjYNDb3JP3sz57J3C8
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-08 04:45:42 +02:00
longjacksonle deleted branch feat/BUNYIP-290-bootstrap-admin-email 2026-07-08 05:04:23 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!349
No description provided.