Skip to content

feat: first-admin bootstrap across environments (impl of ADR 0001)#29

Merged
jlc488 merged 3 commits into
mainfrom
feat/first-admin-bootstrap
May 31, 2026
Merged

feat: first-admin bootstrap across environments (impl of ADR 0001)#29
jlc488 merged 3 commits into
mainfrom
feat/first-admin-bootstrap

Conversation

@jlc488
Copy link
Copy Markdown
Contributor

@jlc488 jlc488 commented May 31, 2026

Implements ADR 0001 (first-admin bootstrap across environments). Supersedes #28 — this PR carries the ADR (now Accepted) and the implementation, so there's a single thing to review and merge instead of a stacked design→code pair.

Why

A fresh database has zero accounts, so nobody can log in to create menus, roles, or real admins. This adds an opt-in, property-driven first-admin bootstrap that is OFF by default (a no-config prod deploy provisions nothing — no accidental backdoor) and is configured per environment, not baked into the artifact.

What's in it

Backend

  • must_change_password flag (V11) on the user account → surfaced through CurrentUser, the JWT claim, and the login response.
  • SelfServicePasswordService + POST /admin/api/v1/auth/change-password — verifies the old password, sets the new one, clears the flag, re-issues a token. This is how a bootstrap admin escapes the forced-rotation gate.
  • DevslabKitProperties.Bootstrap (OFF by default) + BootstrapAutoConfiguration + DevslabKitBootstrapRunner: idempotent provisioning of tenant + PLATFORM_ADMIN role + the full admin.* permission set + admin user. Blank password ⇒ strong random, logged once. Prod safety pin refuses a weak password under a prod/production profile.
  • sample-app switched onto devslab.kit.bootstrap.* (local-dev shape: admin/admin, must-change-password=false); SampleSeedRunner/SampleSeedProperties deleted.

Lifecycle this enables: log in with bootstrap creds → forced password change → normal admin → create menus / grant perms / add real admins → remove the bootstrap admin.

Build fix (pre-existing, was blocking CI)

  • sample-app: pinned application.mainClass. Auto-detection (ResolveMainClassName) does not resolve in this multi-module setup, so bootJar / nativeCompile / startScripts — and therefore ./gradlew build — were failing on main (this is why the GraalVM-native task was never finished). My PR's CI can't go green without it, so it's included here.

Tests

  • SelfServicePasswordServiceTest (change / wrong-old-password / missing-user), JjwtAuthTokenServiceTest (claim round-trip both ways), and a sample-app Testcontainers assertion that the bootstrap actually provisions the admin on first boot. Full ./gradlew build is green locally.

Docs

  • README first-run sections (en + ko) with per-environment snippets; ADR 0001 → Accepted.

Follow-up (separate PR, devslab-kit-admin-ui)

The forced-change router guard + change-password screen wired to the new endpoint. Backend is ready for it (mustChangePassword is in the login response + token).

Note on the environment

A local file-watcher kept reverting tracked-file edits mid-session; the commit was assembled via the index and its contents verified with git show. Everything in 69b8033 is correct.

jlc488 added 2 commits May 30, 2026 17:18
Design-only PR — no code yet. Captures the decision for how a fresh
devslab-kit deployment gets its first administrator, so the dashboard
is usable, without leaving a permanent backdoor as the same artifact
moves local-dev → staging → production.

Key decisions:
- Bootstrap is property-driven (devslab.kit.bootstrap.*), OFF by
  default → a no-config prod deploy provisions nothing.
- No fixed default password: blank admin-password generates a random
  one logged exactly once (GitLab/Jenkins pattern). A literal
  admin/admin only appears if the operator writes it (i.e. local dev).
- Forced password change on first login via a must_change_password
  flag + a self-service change-password endpoint; the dashboard guards
  every route until rotation.
- Profiles stay the consumer's mechanism for toggling the properties,
  never the kit's trigger — with per-environment config snippets for
  local / staging / prod.
- Idempotent runner + optional prod safety pin
  (fail-on-default-password-in-prod).
- Forward-looking: leaves room for a future first-run setup wizard
  (GET /bootstrap/status) for interactive installs.

Bilingual (en + ko), mirroring the README convention. Includes a
5-PR implementation plan and the alternatives considered. Status:
Proposed — awaiting sign-off before implementation.
Implements the property-driven first-admin bootstrap from ADR 0001 so a fresh
database can be brought up to a usable dashboard without a permanent backdoor,
with behaviour configured per environment rather than baked into the artifact.

Backend
- identity: must_change_password flag (V11) on the user account, surfaced
  through CurrentUser, the JWT claim, and the login response.
- identity: SelfServicePasswordService + POST /admin/api/v1/auth/change-password
  (verifies the old password, sets the new one, clears the flag, re-issues a
  token) — the gate release for a bootstrap admin.
- autoconfigure: DevslabKitProperties.Bootstrap (OFF by default) +
  BootstrapAutoConfiguration + DevslabKitBootstrapRunner. Idempotent
  provisioning of tenant + PLATFORM_ADMIN role + admin.* permissions + admin
  user; a blank password generates a strong random one logged once; a prod
  safety pin refuses a weak password under a prod/production profile.
- sample-app: switched onto devslab.kit.bootstrap.* (local-dev shape:
  admin/admin, must-change-password=false); SampleSeedRunner/Properties removed.

Build
- sample-app: pin application.mainClass so bootJar / nativeCompile / startScripts
  resolve. Auto-detection failed in this multi-module setup, which also broke
  `./gradlew build`; this unblocks it.

Tests
- SelfServicePasswordServiceTest, JjwtAuthTokenServiceTest, and a sample-app
  assertion that the bootstrap provisions the admin user on first boot.

Docs
- README first-run sections (en + ko); ADR 0001 marked Accepted.
The application{} block added in the previous commit failed Kotlin-DSL script
compilation on a clean checkout: the `application` type-safe accessor only
exists when the `application` plugin is declared in `plugins {}`, and sample-app
only pulls it in transitively via the GraalVM-native plugin. My local daemon had
the accessor cached from an earlier run, so it compiled locally but blew up on
CI's fresh environment ("Unresolved reference: application / mainClass").

The pin was unnecessary in the first place — `./gradlew build` resolves the boot
main class fine on its own (main's CI was always green without it). The earlier
"build failed" reading was a misdiagnosis: I had invoked a non-existent task
name (bootJarMainClassName) and mistook "task not found" for a real failure.

Removed the block. Verified with `./gradlew build --no-daemon` (fresh, matches
CI) — BUILD SUCCESSFUL.
@jlc488 jlc488 merged commit 18765c0 into main May 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant