From a6d5bb15d7fecf83bc7ade82719d6dde3df05d7d Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 30 May 2026 17:18:51 +0900 Subject: [PATCH 1/3] docs(adr): first-admin bootstrap strategy across environments (ADR 0001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/adr/0001-bootstrap-admin.ko.md | 190 ++++++++++++++++++++++++++ docs/adr/0001-bootstrap-admin.md | 199 ++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 docs/adr/0001-bootstrap-admin.ko.md create mode 100644 docs/adr/0001-bootstrap-admin.md diff --git a/docs/adr/0001-bootstrap-admin.ko.md b/docs/adr/0001-bootstrap-admin.ko.md new file mode 100644 index 0000000..32dfbb5 --- /dev/null +++ b/docs/adr/0001-bootstrap-admin.ko.md @@ -0,0 +1,190 @@ +# ADR 0001 — 환경별 최초 관리자 부트스트랩 + +- **상태:** 제안(Proposed) +- **날짜:** 2026-05-30 +- **언어:** [English](0001-bootstrap-admin.md) · [한국어](0001-bootstrap-admin.ko.md) + +## 배경 + +`devslab-kit` 는 인증, RBAC + 그룹 + ABAC, 멀티테넌시, 메뉴, 감사 로그, admin +REST API 를 제공하고, 이를 `devslab-kit-admin-ui` 대시보드가 소비한다. + +여기엔 닭과 달걀 문제가 있다: 새 데이터베이스에는 **사용자 계정이 0개**라서 +아무도 대시보드에 로그인할 수 없고, 그러면 메뉴를 만들거나 권한을 부여하거나 +*진짜* 관리자를 추가할 수도 없다. 모든 시스템에는 **최초 관리자**를 만드는 +경로가 반드시 있어야 한다. + +어려운 점은 이걸 **영구 백도어를 남기지 않으면서** 하는 것이다. 게다가 +*동일한 산출물(jar)* 이 요구사항이 다른 세 환경을 거쳐 이동한다: + +| 환경 | 운영자가 원하는 동작 | +| --- | --- | +| **local-dev** | `admin / admin`, 즉시 로그인, 가능하면 비번 변경 단계도 건너뛰고 빠르게 | +| **staging** | 부트스트랩하되 비번은 주입, 강제 변경 ON (운영 리허설) | +| **production (운영)** | 고정 기본 비번 절대 금지. 비번 주입 + 강제 변경, 또는 자동 부트스트랩 자체를 끔 | + +### "그냥 Spring 프로파일 쓰면 되지" 가 부족한 이유 + +`@Profile("dev")` 로 부트스트랩을 거는 건 직관적이지만 결함이 있다: + +- **프로파일은 consumer 의 것이다.** 라이브러리가 프로파일 이름에 의존할 수 없다 — + consumer 마다 `local` / `dev` / `development` 로 제각각이다. +- **프로파일은 on/off 만 표현한다.** "비번을 주입했는가?", "강제 변경이 켜졌는가?" + 같은 조건을 담을 수 없다. +- **프로파일 실수는 보안 사고다.** 운영에서 실수로 `dev` 프로파일을 켜면 조용히 + `admin/admin` 백도어가 생성된다. + +업계 표준은 **명시적 property + 안전 가드의 조합**이며, 프로파일은(쓰더라도) +그 property 를 켜는 consumer 쪽 *수단*일 뿐 — kit 의 트리거가 되어선 안 된다. + +## 결정 + +### 1. 부트스트랩은 property 로 제어, 기본값 OFF + +새 `devslab.kit.bootstrap.*` 설정 블록을 두되, consumer 가 명시적으로 켜기 +전까지는 비활성: + +```yaml +devslab.kit.bootstrap: + enabled: false # 기본값 — 설정 없는 운영 배포는 부트스트랩 안 함 + tenant-id: default # 최초 관리자가 속할 테넌트 + admin-login-id: admin + admin-password: # 비우면 → 랜덤 생성 후 로그에 1회 출력 + admin-email: admin@example.com + must-change-password: true # 기본 ON — 첫 로그인 시 비번 변경 강제 + role-code: PLATFORM_ADMIN +``` + +기본이 `enabled: false` 이므로, 부트스트랩 설정 없이 산출물을 배포하면(=운영 +기본) 아무것도 생성하지 않는다. 백도어가 실수로 생길 수 없다. + +### 2. 고정 기본 비번 없음 — 비우면 "랜덤 생성 + 로그 1회" + +`enabled: true` 인데 `admin-password` 가 비어 있으면, 러너가 강력한 랜덤 비번을 +생성해 부팅 로그에 **정확히 한 번** 출력한다: + +``` +============================================================ + devslab-kit bootstrap: created first admin + tenant : default + login : admin + password (shown ONCE — copy it now): a8Kx29... + This account must change its password on first login. +============================================================ +``` + +GitLab / Jenkins / Sonatype 방식이다. 고정 `admin/admin` 은 운영자가 직접 +`admin-password: admin` 이라고 적었을 때**만** 생긴다 — 그게 바로 local-dev +프로파일이 하는 일이고, 운영 설정이 절대 해선 안 되는 일이다. + +### 3. 첫 로그인 시 비번 변경 강제 + +사용자 계정에 `must_change_password` 플래그를 추가한다(부트스트랩 관리자는 기본 +`true`). 플래그가 켜져 있는 동안: + +- 로그인 자체는 인증되고 토큰도 발급되지만, 토큰 / 로그인 응답에 + `mustChangePassword: true` 가 실린다. +- 대시보드는 그 플래그를 보고 사용자를 **비번 변경 화면**으로 보내고, 비번을 + 바꾸기 전까지 다른 모든 라우트를 막는다. +- 새 self-service 엔드포인트 `POST /admin/api/v1/auth/change-password` + (기존 비번 + 새 비번)가 플래그를 해제한다. 이는 기존 "관리자가 남의 비번을 + 리셋하는" 엔드포인트와 구분된다. + +이로써 운영자 시나리오 전체가 작동한다: 부트스트랩 비번으로 로그인 → 새 비번 +강제 설정 → 이제 일반 관리자 → 메뉴 생성 / 권한 부여 / 진짜 관리자 추가 → +(원하면) 부트스트랩 관리자 비활성 또는 삭제. + +### 4. 환경별 사용법 (consumer 쪽) + +kit 은 환경 비의존으로 유지하고, consumer 가 프로파일별 설정 파일에 의도를 표현: + +```yaml +# application.yml (공통) — 여기에 아무것도 없음 = 운영 기본 안전 + +# application-local.yml +devslab.kit.bootstrap: + enabled: true + admin-password: admin + must-change-password: false # 로컬: 바로 로그인 + +# application-staging.yml +devslab.kit.bootstrap: + enabled: true + admin-password: ${BOOTSTRAP_ADMIN_PW} # 주입된 시크릿 + must-change-password: true + +# production (운영) +# 옵션 A: staging 과 동일하게 시크릿 주입 + 강제 변경. +# 옵션 B: bootstrap.enabled=false 로 두고, 최초 관리자를 별도 경로 +# (일회성 잡 / SQL / CLI)로 프로비저닝 → 실행 중인 앱에는 +# 부트스트랩 경로 자체가 없음. +``` + +### 5. 멱등성 + 운영 안전핀 + +- **멱등(idempotent):** 러너는 `(tenant, loginId)` 로 기존 사용자를 확인하고 + 있으면 생성을 건너뛴다. staging DB 를 운영으로 승급하거나 재시작해도 러너는 + no-op 으로 재실행된다. +- **안전핀(선택, 기본 ON):** + `devslab.kit.bootstrap.fail-on-default-password-in-prod: true` — 활성 + 프로파일에 `prod`/`production` 이 있고 **그리고** 결정된 비번이 잘 알려진 약한 + 값(`admin`, `password`, `changeme` …)과 같으면, 앱이 명확한 메시지와 함께 + 부팅에 실패한다. 이건 주 통제가 아니라 백스톱이다(주 통제는 "고정 기본값이 + 애초에 없다"). + +### 6. 프론트 대시보드 영향 (향후 고려) + +- **현재:** 대시보드가 `mustChangePassword` 를 처리해야 한다 — 비번 변경 + 화면으로 보내는 가드 + 새 엔드포인트를 호출하는 작은 화면. (후속 UI PR 로 추적.) +- **추후 — 가이드형 first-run / 설치 마법사:** `admin/admin` 대신, 프로비저닝되지 + 않은 인스턴스의 첫 대시보드 방문에 일회성 "최초 관리자 생성" 폼을 띄울 수 있다 + (Jenkins 설치 화면, GitLab root 비번 화면 같은 방식). 대화형 설치에서는 + config-부트스트랩을 이 마법사가 대체하고, headless / 자동 배포에는 property + 경로가 그대로 남는다. 본 ADR 범위 밖이지만, 부트스트랩 계약이 이를 수용할 + 여지를 남기도록 기록한다(예: 마법사가 분기할 수 있는 + `GET /admin/api/v1/bootstrap/status` → `{ initialized: boolean }`). + +## 결과(Consequences) + +**긍정** +- 산출물 하나로 세 환경, 재빌드 없음 — 동작이 코드가 아니라 설정이다. +- 기본 안전(OFF)이고, 켜더라도 고정 비번이 새지 않는다. +- "로그인 → 비번 교체 → 인계 → 부트스트랩 관리자 제거" 전체 라이프사이클을 + 표현할 수 있다. +- `sample-app` 의 현재 `SampleSeedRunner` 가 이 한 메커니즘으로 흡수된다 + (dev 설정에서 `bootstrap.enabled=true, admin-password=admin, + must-change-password=false`), 중복 로직 제거. + +**부정 / 비용** +- 움직이는 부품이 여럿: 스키마 마이그레이션(`must_change_password`), identity + 필드, self-service change-password 엔드포인트 + 로그인 응답 필드, autoconfig + 러너, UI 가드 + 화면. 여러 PR 로 나누는 게 낫다(구현 계획 참조). +- 강제 변경은 명시적으로 끄지 않는 한 dev 에서 로그인 왕복을 한 번 추가한다. +- 랜덤-비번-로그 방식은 운영자가 첫 부팅 로그를 읽을 수 있다고 전제한다 — + 문서에 눈에 띄게 명시. + +## 구현 계획 (PR 분할) + +1. **identity: `must_change_password`** — 마이그레이션 `V11`, 엔티티 필드, + `CurrentUser` 필드, 로그인 응답 + JWT 클레임에 노출. +2. **identity: self-service change-password** — 기존 비번 검증 후 새 비번 설정 + + 플래그 해제하는 `LocalLoginService` / 계정 서비스 메서드; + `POST /admin/api/v1/auth/change-password`. +3. **autoconfigure: `BootstrapAutoConfiguration` + `DevslabKitBootstrapRunner`** + — property 블록, 랜덤 비번 생성 + 1회 로그, 멱등 프로비저닝, 운영 안전핀. + `sample-app` 이 이걸 쓰도록 전환하고 `SampleSeedRunner` 삭제. +4. **admin-ui: 강제 변경 가드 + 화면** — `mustChangePassword` 라우터 가드, + 비번 변경 뷰, 새 엔드포인트 연동. +5. **docs:** 양쪽 README 의 "first run" 섹션이 이 ADR 을 참조하도록 갱신; + 환경별 설정 스니펫 추가. + +## 검토한 대안 + +- **프로파일 게이트 부트스트랩(`@Profile("dev")`):** 기각 — *배경* 참조. +- **고정 기본값으로 `first-run` 자동 생성, opt-in 없음:** 기각 — 라이브러리의 + 고정 기본값은 잠재적 운영 백도어다. +- **비번 미주입 시 `fail-fast`(dev 에서도):** 가능하고 더 엄격하지만 local-dev + 편의가 나쁘다; 랜덤-비번-로그 경로가 같은 운영 안전을 더 친절한 기본값으로 + 제공한다. consumer 의 선택지로 남긴다(항상 비번을 주입하면 됨). +- **부트스트랩 아예 없음(consumer 책임):** 가장 안전하지만, 이 질문을 촉발한 + "스타터 넣고 바로 시작" 목표를 달성하지 못한다. diff --git a/docs/adr/0001-bootstrap-admin.md b/docs/adr/0001-bootstrap-admin.md new file mode 100644 index 0000000..c88f678 --- /dev/null +++ b/docs/adr/0001-bootstrap-admin.md @@ -0,0 +1,199 @@ +# ADR 0001 — First-admin bootstrap across environments + +- **Status:** Proposed +- **Date:** 2026-05-30 +- **Languages:** [English](0001-bootstrap-admin.md) · [한국어](0001-bootstrap-admin.ko.md) + +## Context + +`devslab-kit` ships authentication, RBAC + groups + ABAC, multi-tenancy, menus, +audit, and an admin REST API, consumed by the `devslab-kit-admin-ui` dashboard. + +There is a chicken-and-egg problem: a fresh database has **zero user accounts**, +so nobody can log in to the dashboard, so nobody can create menus, grant +permissions, or add the *real* administrators. Every system needs a way to +mint a **first administrator**. + +The hard part is doing this **without leaving a permanent backdoor**, while the +*same artifact* moves through three environments with different needs: + +| Environment | What the operator wants | +| --- | --- | +| **local-dev** | `admin / admin`, log in instantly, ideally skip the password-change step for speed | +| **staging** | Bootstrap, but with an injected password and forced change (a production rehearsal) | +| **production (운영)** | No fixed default password, ever. Injected password + forced change, or no auto-bootstrap at all | + +### Why "just use a Spring profile" is not enough + +Gating bootstrap on `@Profile("dev")` is tempting but flawed: + +- **Profiles belong to the consumer.** A library cannot reliably key off a + profile name — consumers call it `local` / `dev` / `development` inconsistently. +- **Profiles are on/off only.** They cannot express "was a password injected?" + or "is forced-change on?". +- **A profile slip is a security incident.** Turning on `dev` in production + would silently create an `admin/admin` backdoor. + +The industry-standard approach is **explicit properties plus safety guards**, +where a profile (if used at all) is just the consumer's *mechanism* for turning +those properties on — never the kit's trigger. + +## Decision + +### 1. Bootstrap is property-driven, OFF by default + +A new `devslab.kit.bootstrap.*` configuration block, disabled unless the +consumer explicitly opts in: + +```yaml +devslab.kit.bootstrap: + enabled: false # default — a no-config prod deploy never bootstraps + tenant-id: default # tenant the first admin belongs to + admin-login-id: admin + admin-password: # blank → generate a random one and log it once + admin-email: admin@example.com + must-change-password: true # default ON — the admin must rotate on first login + role-code: PLATFORM_ADMIN +``` + +Because the default is `enabled: false`, deploying the artifact with no +bootstrap config — the production default — provisions nothing. The backdoor +cannot exist by accident. + +### 2. No fixed default password — blank means "random, logged once" + +When `enabled: true` but `admin-password` is blank, the runner generates a +strong random password and writes it to the boot log **exactly once**: + +``` +============================================================ + devslab-kit bootstrap: created first admin + tenant : default + login : admin + password (shown ONCE — copy it now): a8Kx29... + This account must change its password on first login. +============================================================ +``` + +This is the GitLab / Jenkins / Sonatype pattern. A fixed `admin/admin` can +**only** appear if the operator wrote `admin-password: admin` themselves — which +is exactly what a local-dev profile does, and exactly what a production config +must not. + +### 3. Forced password change on first login + +A new `must_change_password` flag on the user account (default `true` for +bootstrapped admins). While the flag is set: + +- Login still authenticates and issues a token, but the token / login response + carries `mustChangePassword: true`. +- The dashboard, seeing that flag, routes the user to a **change-password + screen** and blocks every other route until the password is rotated. +- A new self-service endpoint `POST /admin/api/v1/auth/change-password` + (old + new password) clears the flag. This is distinct from the existing + admin-resets-someone-else's-password endpoint. + +So the full operator story works: log in with the bootstrap password → forced +to set a new one → now a normal admin → create menus, grant permissions, add +real administrators → optionally disable or delete the bootstrap admin. + +### 4. Per-environment usage (consumer side) + +The kit stays environment-agnostic; the consumer expresses intent in +profile-specific config files: + +```yaml +# application.yml (shared) — nothing here = production-safe by default + +# application-local.yml +devslab.kit.bootstrap: + enabled: true + admin-password: admin + must-change-password: false # local: log straight in + +# application-staging.yml +devslab.kit.bootstrap: + enabled: true + admin-password: ${BOOTSTRAP_ADMIN_PW} # injected secret + must-change-password: true + +# production +# Option A: same as staging with an injected secret + forced change. +# Option B: leave bootstrap.enabled=false and provision the first admin +# out-of-band (one-off job / SQL / a CLI), so the running app +# never carries a bootstrap path at all. +``` + +### 5. Idempotency and a production safety pin + +- **Idempotent:** the runner checks for an existing user by `(tenant, loginId)` + and skips creation if present. A staging database promoted to production, or + any restart, re-runs the runner as a no-op. +- **Safety pin (optional, default on):** + `devslab.kit.bootstrap.fail-on-default-password-in-prod: true` — if the active + profiles include `prod`/`production` **and** the resolved password equals a + well-known weak value (`admin`, `password`, `changeme`, …), the app fails to + start with a clear message. This is a backstop, not the primary control (the + primary control is "no fixed default exists"). + +### 6. Frontend dashboard implications (forward-looking) + +- **Now:** the dashboard must handle `mustChangePassword` — a guard that + diverts to the change-password screen and a small view calling the new + endpoint. (Tracked as a follow-up UI PR.) +- **Later — a guided first-run / setup wizard:** instead of `admin/admin`, the + very first dashboard visit on an un-provisioned instance could present a + one-time "create the first administrator" form (à la Jenkins setup, GitLab + root password screen). That replaces bootstrap-by-config for interactive + installs while the property path remains for headless / automated deploys. + Out of scope for this ADR; recorded so the bootstrap contract leaves room + for it (e.g. a `GET /admin/api/v1/bootstrap/status` returning + `{ initialized: boolean }` the wizard can branch on). + +## Consequences + +**Positive** +- One artifact, three environments, no rebuild — behaviour is config, not code. +- Production-safe by default (OFF), and even when on, no fixed password can leak. +- The full "log in → rotate → hand off → remove bootstrap admin" lifecycle is + expressible. +- The `sample-app`'s current `SampleSeedRunner` collapses into this one + mechanism (`bootstrap.enabled=true, admin-password=admin, + must-change-password=false` in its dev config), removing duplicated logic. + +**Negative / cost** +- Several moving parts: a schema migration (`must_change_password`), an + identity field, a self-service change-password endpoint + login-response + field, the autoconfig runner, and a UI guard + screen. Best split across a + few PRs (see Implementation plan). +- Forced-change adds one login round-trip in dev unless explicitly turned off. +- The random-password-in-logs pattern assumes operators can read the boot log + on first start; documented prominently. + +## Implementation plan (PR breakdown) + +1. **identity: `must_change_password`** — migration `V11`, entity field, + `CurrentUser` field, surfaced in the login response + JWT claim. +2. **identity: self-service change-password** — `LocalLoginService` / + account service method that verifies the old password, sets the new one, + clears the flag; `POST /admin/api/v1/auth/change-password`. +3. **autoconfigure: `BootstrapAutoConfiguration` + `DevslabKitBootstrapRunner`** + — the property block, random-password generation + one-time log, idempotent + provisioning, the prod safety pin. `sample-app` switches to it and deletes + `SampleSeedRunner`. +4. **admin-ui: forced-change guard + screen** — router guard on + `mustChangePassword`, a change-password view, wire to the new endpoint. +5. **docs:** update both READMEs' "first run" sections to reference this ADR; + add the per-environment config snippets. + +## Alternatives considered + +- **Profile-gated bootstrap (`@Profile("dev")`):** rejected — see *Context*. +- **`first-run` auto-create with a fixed default, no opt-in:** rejected — a + fixed default in a library is a latent production backdoor. +- **`fail-fast` when no password injected (even in dev):** viable and stricter, + but worse local-dev ergonomics; the random-password-logged path gives the + same prod safety with a friendlier default. Kept as the consumer's choice + (they can simply always inject a password). +- **No bootstrap at all (consumer's problem):** safest, but fails the "drop in + the starter and start" goal that motivated the question. From 48be6c1c0f82e10cf34fa943f3ce4642b349275e Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 09:35:26 +0900 Subject: [PATCH 2/3] feat: first-admin bootstrap across environments (ADR 0001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../kit/admin/auth/AuthController.java | 46 +++- .../kit/admin/auth/ChangePasswordRequest.java | 15 ++ .../devslab/kit/admin/auth/LoginResponse.java | 6 +- .../BootstrapAutoConfiguration.java | 68 +++++ .../DevslabKitBootstrapRunner.java | 243 ++++++++++++++++++ .../autoconfigure/DevslabKitProperties.java | 131 ++++++++++ .../IdentityAutoConfiguration.java | 11 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../kr/devslab/kit/identity/CurrentUser.java | 15 +- .../entity/PlatformUserAccountEntity.java | 8 + .../core/service/JjwtAuthTokenService.java | 6 +- .../core/service/LocalLoginService.java | 3 +- .../PlatformUserAccountAdminService.java | 13 + .../service/SelfServicePasswordService.java | 62 +++++ ...form_user_account_must_change_password.sql | 2 + .../service/JjwtAuthTokenServiceTest.java | 58 +++++ .../SelfServicePasswordServiceTest.java | 93 +++++++ devslab-kit-sample-app/build.gradle.kts | 11 + .../devslab/kit/sample/SampleApplication.java | 48 +--- .../kit/sample/SampleSeedProperties.java | 26 -- .../devslab/kit/sample/SampleSeedRunner.java | 180 ------------- .../src/main/resources/application.yaml | 25 +- .../kit/sample/SampleApplicationTests.java | 20 ++ docs/adr/0001-bootstrap-admin.ko.md | 9 +- docs/adr/0001-bootstrap-admin.md | 9 +- 25 files changed, 844 insertions(+), 265 deletions(-) create mode 100644 devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/ChangePasswordRequest.java create mode 100644 devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/BootstrapAutoConfiguration.java create mode 100644 devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitBootstrapRunner.java create mode 100644 devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/SelfServicePasswordService.java create mode 100644 devslab-kit-identity-core/src/main/resources/db/migration/V11__platform_user_account_must_change_password.sql create mode 100644 devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java create mode 100644 devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/SelfServicePasswordServiceTest.java delete mode 100644 devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedProperties.java delete mode 100644 devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedRunner.java diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/AuthController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/AuthController.java index 99135ff..16e0469 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/AuthController.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/AuthController.java @@ -5,12 +5,16 @@ import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; import kr.devslab.kit.admin.AdminApiPaths; import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.identity.AccountLoginException; import kr.devslab.kit.identity.AuthToken; import kr.devslab.kit.identity.AuthTokenService; import kr.devslab.kit.identity.CurrentUser; +import kr.devslab.kit.identity.CurrentUserProvider; import kr.devslab.kit.identity.LoginCommand; +import kr.devslab.kit.identity.LoginFailureReason; import kr.devslab.kit.identity.LoginResult; import kr.devslab.kit.identity.core.service.LocalLoginService; +import kr.devslab.kit.identity.core.service.SelfServicePasswordService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -24,15 +28,21 @@ public class AuthController { private final LocalLoginService loginService; private final AuthTokenService tokenService; private final JpaPlatformRoleRepository roleRepository; + private final CurrentUserProvider currentUserProvider; + private final SelfServicePasswordService passwordService; public AuthController( LocalLoginService loginService, AuthTokenService tokenService, - JpaPlatformRoleRepository roleRepository + JpaPlatformRoleRepository roleRepository, + CurrentUserProvider currentUserProvider, + SelfServicePasswordService passwordService ) { this.loginService = loginService; this.tokenService = tokenService; this.roleRepository = roleRepository; + this.currentUserProvider = currentUserProvider; + this.passwordService = passwordService; } @PostMapping("/login") @@ -54,9 +64,41 @@ public ResponseEntity login(@Valid @RequestBody LoginRequest req) base.tenantId(), base.loginId(), base.status(), - roleCodes + roleCodes, + base.mustChangePassword() ); AuthToken token = tokenService.issue(enriched); return ResponseEntity.ok(LoginResponse.of(token, enriched)); } + + /** + * Self-service password change for the authenticated caller. Verifies the + * current password, stores the new one, and clears the forced-rotation + * flag — this is how a bootstrap admin (ADR 0001) escapes the + * {@code mustChangePassword} gate. Returns a freshly minted token (now with + * {@code mustChangePassword=false}) so the dashboard can swap it in without + * a re-login round-trip. + */ + @PostMapping("/change-password") + public ResponseEntity changePassword(@Valid @RequestBody ChangePasswordRequest req) { + CurrentUser principal = currentUserProvider.current() + .orElseThrow(() -> new AccountLoginException(LoginFailureReason.UNKNOWN_USER)); + + passwordService.changePassword(principal.id(), req.oldPassword(), req.newPassword()); + + // Re-issue with the flag cleared, re-reading roles so the new token is + // consistent with login's response shape. + Set roleCodes = roleRepository.findRoleCodesForUserId(principal.id().value()); + CurrentUser updated = new CurrentUser( + principal.id(), + principal.publicId(), + principal.tenantId(), + principal.loginId(), + principal.status(), + roleCodes, + false + ); + AuthToken token = tokenService.issue(updated); + return ResponseEntity.ok(LoginResponse.of(token, updated)); + } } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/ChangePasswordRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/ChangePasswordRequest.java new file mode 100644 index 0000000..674c65f --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package kr.devslab.kit.admin.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Self-service password change payload. The caller must be authenticated; the + * account is taken from the security principal, not the request body, so a + * user can only change their own password. + */ +public record ChangePasswordRequest( + @NotBlank @Size(max = 255) String oldPassword, + @NotBlank @Size(min = 8, max = 255) String newPassword +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginResponse.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginResponse.java index 2a57676..04c6965 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginResponse.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/auth/LoginResponse.java @@ -17,7 +17,8 @@ public static LoginResponse of(AuthToken token, CurrentUser user) { user.tenantId().value(), user.loginId(), user.status().name(), - user.roles() + user.roles(), + user.mustChangePassword() ) ); } @@ -28,7 +29,8 @@ public record UserSummary( String tenantId, String loginId, String status, - Set roles + Set roles, + boolean mustChangePassword ) { } } diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/BootstrapAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/BootstrapAutoConfiguration.java new file mode 100644 index 0000000..07e62ed --- /dev/null +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/BootstrapAutoConfiguration.java @@ -0,0 +1,68 @@ +package kr.devslab.kit.autoconfigure; + +import jakarta.persistence.EntityManager; +import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; +import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; +import kr.devslab.kit.access.core.service.PermissionAdminService; +import kr.devslab.kit.access.core.service.RoleAdminService; +import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; +import kr.devslab.kit.tenant.TenantService; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; + +/** + * Wires the first-admin bootstrap runner (ADR 0001) when + * {@code devslab.kit.bootstrap.enabled=true}. OFF by default — there is no + * {@code matchIfMissing}, so a deploy with no bootstrap config provisions + * nothing and cannot grow an accidental backdoor. + * + *

Ordered after the identity / access / tenant / admin-api auto-configs so + * all the services it needs are already defined. + */ +@AutoConfiguration(after = { + IdentityAutoConfiguration.class, + AccessAutoConfiguration.class, + TenantAutoConfiguration.class, + AdminApiAutoConfiguration.class +}) +@ConditionalOnClass(EntityManager.class) +@ConditionalOnProperty(prefix = "devslab.kit.bootstrap", name = "enabled", havingValue = "true") +public class BootstrapAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public DevslabKitBootstrapRunner devslabKitBootstrapRunner( + DevslabKitProperties properties, + Environment environment, + TenantService tenantService, + PermissionAdminService permissionAdminService, + JpaPlatformPermissionRepository permissionRepository, + RoleAdminService roleAdminService, + JpaPlatformRoleRepository roleRepository, + RolePermissionService rolePermissionService, + PlatformUserAccountAdminService userAdminService, + JpaPlatformUserAccountRepository userRepository, + UserRoleService userRoleService + ) { + return new DevslabKitBootstrapRunner( + properties.getBootstrap(), + environment, + tenantService, + permissionAdminService, + permissionRepository, + roleAdminService, + roleRepository, + rolePermissionService, + userAdminService, + userRepository, + userRoleService + ); + } +} diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitBootstrapRunner.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitBootstrapRunner.java new file mode 100644 index 0000000..a7a350e --- /dev/null +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitBootstrapRunner.java @@ -0,0 +1,243 @@ +package kr.devslab.kit.autoconfigure; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; +import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; +import kr.devslab.kit.access.core.service.PermissionAdminService; +import kr.devslab.kit.access.core.service.RoleAdminService; +import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; +import kr.devslab.kit.core.id.PermissionId; +import kr.devslab.kit.core.id.RoleId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; +import kr.devslab.kit.tenant.TenantMetadata; +import kr.devslab.kit.tenant.TenantMode; +import kr.devslab.kit.tenant.TenantService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.env.Environment; +import org.springframework.transaction.annotation.Transactional; + +/** + * Idempotent first-admin bootstrap (ADR 0001). + * + *

When {@code devslab.kit.bootstrap.enabled=true}, provisions just enough on + * first boot for the dashboard to be usable end-to-end: + * + *

    + *
  • The configured tenant (default {@code default}).
  • + *
  • An admin role (default {@code PLATFORM_ADMIN}) bound to that tenant.
  • + *
  • The {@code admin.*} permission set covering every admin-api endpoint, + * granted to that role.
  • + *
  • One admin user (default {@code admin}) holding the role.
  • + *
+ * + *

Every step is idempotent — re-boots find each entity by code / loginId and + * skip creation, so running against an already-provisioned database is a no-op. + * + *

Password handling follows the GitLab / Jenkins pattern: a blank + * {@code admin-password} means "generate a strong random one and log it exactly + * once". A fixed password only exists when an operator wrote it into config — + * which is what a local-dev profile does and what a production profile must not. + * The prod safety pin fails startup if a well-known weak password is configured + * under a {@code prod}/{@code production} profile. + */ +public class DevslabKitBootstrapRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(DevslabKitBootstrapRunner.class); + + private static final Set WEAK_PASSWORDS = Set.of( + "admin", "password", "passwd", "changeme", "change-me", "admin123", + "root", "123456", "12345678", "secret", "default", "test" + ); + + private static final List ADMIN_PERMISSIONS = List.of( + new PermissionSeed("admin.user.read", "Read user accounts via /admin/api/v1/users"), + new PermissionSeed("admin.user.write", "Create / update / delete user accounts"), + new PermissionSeed("admin.role.read", "Read roles and their permission grants"), + new PermissionSeed("admin.role.write", "Create / rename / delete roles and grant permissions"), + new PermissionSeed("admin.permission.read", "Read permission catalogue"), + new PermissionSeed("admin.permission.write", "Create / update / delete permissions"), + new PermissionSeed("admin.group.read", "Read groups and memberships"), + new PermissionSeed("admin.group.write", "Create / rename / delete groups and manage memberships"), + new PermissionSeed("admin.menu.read", "Read menu tree"), + new PermissionSeed("admin.menu.write", "Create / update / delete menu items"), + new PermissionSeed("admin.tenant.read", "Read tenants"), + new PermissionSeed("admin.tenant.write", "Create / rename / change status / delete tenants"), + new PermissionSeed("admin.policy.test", "Run policy dry-run via /admin/api/v1/policies/test"), + new PermissionSeed("admin.audit.read", "Read audit logs with filters + paging"), + new PermissionSeed("admin.diagnostics.run", "Run diagnostics probes (login-test, permission-check, menu-visibility)"), + new PermissionSeed("admin.settings.read", "Read DevslabKitProperties via /admin/api/v1/settings") + ); + + private final DevslabKitProperties.Bootstrap props; + private final Environment environment; + private final TenantService tenantService; + private final PermissionAdminService permissionAdminService; + private final JpaPlatformPermissionRepository permissionRepository; + private final RoleAdminService roleAdminService; + private final JpaPlatformRoleRepository roleRepository; + private final RolePermissionService rolePermissionService; + private final PlatformUserAccountAdminService userAdminService; + private final JpaPlatformUserAccountRepository userRepository; + private final UserRoleService userRoleService; + + public DevslabKitBootstrapRunner( + DevslabKitProperties.Bootstrap props, + Environment environment, + TenantService tenantService, + PermissionAdminService permissionAdminService, + JpaPlatformPermissionRepository permissionRepository, + RoleAdminService roleAdminService, + JpaPlatformRoleRepository roleRepository, + RolePermissionService rolePermissionService, + PlatformUserAccountAdminService userAdminService, + JpaPlatformUserAccountRepository userRepository, + UserRoleService userRoleService + ) { + this.props = props; + this.environment = environment; + this.tenantService = tenantService; + this.permissionAdminService = permissionAdminService; + this.permissionRepository = permissionRepository; + this.roleAdminService = roleAdminService; + this.roleRepository = roleRepository; + this.rolePermissionService = rolePermissionService; + this.userAdminService = userAdminService; + this.userRepository = userRepository; + this.userRoleService = userRoleService; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + // Fail fast on a weak, explicitly-configured password under a prod + // profile — regardless of whether the user already exists, so a + // misconfiguration surfaces at startup rather than silently. + String configured = props.getAdminPassword(); + boolean generatePassword = configured == null || configured.isBlank(); + if (!generatePassword + && props.isFailOnDefaultPasswordInProd() + && isProductionProfile() + && WEAK_PASSWORDS.contains(configured.toLowerCase(Locale.ROOT))) { + throw new IllegalStateException( + "devslab-kit bootstrap refuses to start: a well-known weak password is configured " + + "for the bootstrap admin under a production profile. Set " + + "devslab.kit.bootstrap.admin-password to a strong value (or leave it blank " + + "for a random one), or disable this pin with " + + "devslab.kit.bootstrap.fail-on-default-password-in-prod=false."); + } + + TenantId tenant = ensureTenant(); + RoleId adminRole = ensureAdminRole(tenant); + ensureAdminPermissionsAndGrants(adminRole); + + UserId adminUser = userRepository + .findByTenantIdAndLoginId(tenant.value(), props.getAdminLoginId()) + .map(entity -> { + log.info("[devslab-kit bootstrap] admin user '{}' already exists in tenant '{}' — skipping create", + props.getAdminLoginId(), tenant.value()); + return UserId.of(entity.getId()); + }) + .orElseGet(() -> createAdminUser(tenant, generatePassword ? generateRandomPassword() : configured, generatePassword)); + + userRoleService.assign(adminUser, adminRole, tenant); // idempotent + + log.info("[devslab-kit bootstrap] complete: tenant={} role={} user={}", + tenant.value(), props.getRoleCode(), props.getAdminLoginId()); + } + + private TenantId ensureTenant() { + TenantId id = TenantId.of(props.getTenantId()); + if (tenantService.findById(id).isPresent()) { + return id; + } + TenantMetadata created = tenantService.create(id, "Default Tenant", TenantMode.SINGLE); + log.info("[devslab-kit bootstrap] created tenant {}", created.id().value()); + return id; + } + + private RoleId ensureAdminRole(TenantId tenant) { + return roleRepository.findByTenantIdAndCode(tenant.value(), props.getRoleCode()) + .map(entity -> RoleId.of(entity.getId())) + .orElseGet(() -> { + var role = roleAdminService.create(tenant, props.getRoleCode(), props.getRoleName()); + log.info("[devslab-kit bootstrap] created role {}", props.getRoleCode()); + return role.id(); + }); + } + + private void ensureAdminPermissionsAndGrants(RoleId adminRole) { + for (PermissionSeed perm : ADMIN_PERMISSIONS) { + PermissionId permId = permissionRepository.findByCode(perm.code()) + .map(entity -> PermissionId.of(entity.getId())) + .orElseGet(() -> { + permissionAdminService.create(perm.code(), perm.description()); + log.info("[devslab-kit bootstrap] created permission {}", perm.code()); + return permissionRepository.findByCode(perm.code()) + .map(e -> PermissionId.of(e.getId())) + .orElseThrow(); + }); + rolePermissionService.grant(adminRole, permId); // idempotent + } + } + + private UserId createAdminUser(TenantId tenant, String password, boolean generated) { + var view = userAdminService.create( + tenant, + props.getAdminLoginId(), + props.getAdminEmail(), + password, + "LOCAL", + props.isMustChangePassword() + ); + if (generated) { + logGeneratedPassword(tenant, password); + } else { + log.info("[devslab-kit bootstrap] created admin user '{}' in tenant '{}' (password from config, mustChangePassword={})", + props.getAdminLoginId(), tenant.value(), props.isMustChangePassword()); + } + return view.id(); + } + + private void logGeneratedPassword(TenantId tenant, String password) { + log.warn(""" + + ============================================================ + devslab-kit bootstrap: created first admin + tenant : {} + login : {} + password (shown ONCE — copy it now): {} + mustChangePassword: {} + ============================================================""", + tenant.value(), props.getAdminLoginId(), password, props.isMustChangePassword()); + } + + private boolean isProductionProfile() { + for (String profile : environment.getActiveProfiles()) { + String p = profile.toLowerCase(Locale.ROOT); + if (p.equals("prod") || p.equals("production")) { + return true; + } + } + return false; + } + + private static String generateRandomPassword() { + byte[] buf = new byte[24]; + new SecureRandom().nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + private record PermissionSeed(String code, String description) { + } +} diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java index 3f317c3..772647f 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java @@ -11,11 +11,16 @@ public class DevslabKitProperties { private final Tenant tenant = new Tenant(); private final Menu menu = new Menu(); private final Audit audit = new Audit(); + private final Bootstrap bootstrap = new Bootstrap(); public Identity getIdentity() { return identity; } + public Bootstrap getBootstrap() { + return bootstrap; + } + public Access getAccess() { return access; } @@ -236,4 +241,130 @@ public void setAsyncQueueCapacity(int asyncQueueCapacity) { this.asyncQueueCapacity = asyncQueueCapacity; } } + + /** + * First-admin bootstrap (ADR 0001). Disabled by default so a no-config + * production deploy provisions nothing. When enabled, an idempotent runner + * creates a tenant, an admin role with the full {@code admin.*} permission + * set, and a single admin user on first boot — just enough to log in to the + * dashboard and take over. + * + *

Drive it per environment from profile-specific config (e.g. + * {@code application-local.yml} sets {@code admin-password: admin} with + * {@code must-change-password: false}; staging/prod inject a secret and + * leave the forced rotation on). Never commit a fixed password to the + * shared {@code application.yml}. + */ + public static class Bootstrap { + + /** Master switch. Default OFF — bootstrap runs only when explicitly enabled. */ + private boolean enabled = false; + + /** Tenant the first admin belongs to; created if absent. */ + private String tenantId = "default"; + + private String adminLoginId = "admin"; + + /** + * Admin password. Leave blank to generate a strong random one and log + * it exactly once on first boot (GitLab / Jenkins style). A fixed value + * only ever exists because an operator wrote it here. + */ + private String adminPassword; + + private String adminEmail = "admin@example.com"; + + /** Code of the role granted to the bootstrap admin; created if absent. */ + private String roleCode = "PLATFORM_ADMIN"; + + /** Display name used when the role has to be created. */ + private String roleName = "Platform Admin"; + + /** + * When true (default), the bootstrap admin must rotate its password on + * first login. Local-dev typically sets this to {@code false} to skip + * the extra round-trip. + */ + private boolean mustChangePassword = true; + + /** + * Backstop, not the primary control: if the active profiles include + * {@code prod}/{@code production} and an explicitly-configured password + * is a well-known weak value ({@code admin}, {@code password}, …), fail + * startup. The primary control is that no fixed default exists at all. + */ + private boolean failOnDefaultPasswordInProd = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public String getAdminLoginId() { + return adminLoginId; + } + + public void setAdminLoginId(String adminLoginId) { + this.adminLoginId = adminLoginId; + } + + public String getAdminPassword() { + return adminPassword; + } + + public void setAdminPassword(String adminPassword) { + this.adminPassword = adminPassword; + } + + public String getAdminEmail() { + return adminEmail; + } + + public void setAdminEmail(String adminEmail) { + this.adminEmail = adminEmail; + } + + public String getRoleCode() { + return roleCode; + } + + public void setRoleCode(String roleCode) { + this.roleCode = roleCode; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + public boolean isMustChangePassword() { + return mustChangePassword; + } + + public void setMustChangePassword(boolean mustChangePassword) { + this.mustChangePassword = mustChangePassword; + } + + public boolean isFailOnDefaultPasswordInProd() { + return failOnDefaultPasswordInProd; + } + + public void setFailOnDefaultPasswordInProd(boolean failOnDefaultPasswordInProd) { + this.failOnDefaultPasswordInProd = failOnDefaultPasswordInProd; + } + } } diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java index 359fefa..4678e7f 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java @@ -10,6 +10,7 @@ import kr.devslab.kit.identity.core.service.JjwtAuthTokenService; import kr.devslab.kit.identity.core.service.LocalLoginService; import kr.devslab.kit.identity.core.service.PlatformUserAccountService; +import kr.devslab.kit.identity.core.service.SelfServicePasswordService; import jakarta.persistence.EntityManager; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -69,6 +70,16 @@ public PlatformUserAccountService platformUserAccountService(JpaPlatformUserAcco return new PlatformUserAccountService(repository); } + @Bean + @ConditionalOnMissingBean + public SelfServicePasswordService selfServicePasswordService( + JpaPlatformUserAccountRepository repository, + PasswordHasher passwordHasher, + Clock clock + ) { + return new SelfServicePasswordService(repository, passwordHasher, clock); + } + @Bean @ConditionalOnMissingBean public CurrentUserProvider currentUserProvider() { diff --git a/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index f6d6fa0..81ee624 100644 --- a/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -4,4 +4,5 @@ kr.devslab.kit.autoconfigure.AccessAutoConfiguration kr.devslab.kit.autoconfigure.MenuAutoConfiguration kr.devslab.kit.autoconfigure.AuditAutoConfiguration kr.devslab.kit.autoconfigure.AdminApiAutoConfiguration +kr.devslab.kit.autoconfigure.BootstrapAutoConfiguration kr.devslab.kit.autoconfigure.MetricsAutoConfiguration diff --git a/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/CurrentUser.java b/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/CurrentUser.java index 52fe47a..e9904d0 100644 --- a/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/CurrentUser.java +++ b/devslab-kit-identity-api/src/main/java/kr/devslab/kit/identity/CurrentUser.java @@ -11,10 +11,23 @@ public record CurrentUser( TenantId tenantId, String loginId, UserStatus status, - Set roles + Set roles, + boolean mustChangePassword ) { public CurrentUser { roles = roles == null ? Set.of() : Set.copyOf(roles); } + + /** Convenience overload for callers that don't carry the forced-rotation flag. */ + public CurrentUser( + UserId id, + PublicId publicId, + TenantId tenantId, + String loginId, + UserStatus status, + Set roles + ) { + this(id, publicId, tenantId, loginId, status, roles, false); + } } diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java index 38ed2ed..7289072 100644 --- a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java @@ -56,6 +56,14 @@ public class PlatformUserAccountEntity { @Column(name = "locked_until") private Instant lockedUntil; + /** + * When {@code true}, the account holder must rotate their password before + * using the rest of the system. Set by the bootstrap runner for the first + * admin (see ADR 0001) and cleared by the self-service change-password flow. + */ + @Column(name = "must_change_password", nullable = false) + private boolean mustChangePassword; + @Column(name = "created_at", nullable = false) private Instant createdAt; diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java index 2511b38..9c4d2e8 100644 --- a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenService.java @@ -28,6 +28,7 @@ public class JjwtAuthTokenService implements AuthTokenService { private static final String CLAIM_LOGIN_ID = "loginId"; private static final String CLAIM_STATUS = "status"; private static final String CLAIM_ROLES = "roles"; + private static final String CLAIM_MUST_CHANGE_PASSWORD = "mustChangePassword"; private final SecretKey signingKey; private final Duration ttl; @@ -59,6 +60,7 @@ public AuthToken issue(CurrentUser user) { .claim(CLAIM_LOGIN_ID, user.loginId()) .claim(CLAIM_STATUS, user.status().name()) .claim(CLAIM_ROLES, user.roles()) + .claim(CLAIM_MUST_CHANGE_PASSWORD, user.mustChangePassword()) .signWith(signingKey, Jwts.SIG.HS256) .compact(); return new AuthToken(token, now, exp); @@ -79,13 +81,15 @@ public Optional parse(String token) { UUID userId = UUID.fromString(claims.getSubject()); @SuppressWarnings("unchecked") Set roles = Set.copyOf(((java.util.Collection) claims.getOrDefault(CLAIM_ROLES, Set.of()))); + Boolean mustChangePassword = claims.get(CLAIM_MUST_CHANGE_PASSWORD, Boolean.class); CurrentUser user = new CurrentUser( UserId.of(userId), PublicId.of(claims.get(CLAIM_PUBLIC_ID, String.class)), TenantId.of(claims.get(CLAIM_TENANT, String.class)), claims.get(CLAIM_LOGIN_ID, String.class), UserStatus.valueOf(claims.get(CLAIM_STATUS, String.class)), - roles + roles, + mustChangePassword != null && mustChangePassword ); return Optional.of(user); } catch (JwtException | IllegalArgumentException ex) { diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java index fe7c05a..f8d94a1 100644 --- a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java @@ -108,7 +108,8 @@ public LoginResult login(LoginCommand command) { tenantId, loginId, account.getStatus(), - Set.of() + Set.of(), + account.isMustChangePassword() ); return new LoginResult(currentUser); } diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/PlatformUserAccountAdminService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/PlatformUserAccountAdminService.java index 4d34b68..0759eaa 100644 --- a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/PlatformUserAccountAdminService.java +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/PlatformUserAccountAdminService.java @@ -37,6 +37,18 @@ public PlatformUserAccountAdminService( @Transactional public UserAccountView create(TenantId tenantId, String loginId, String email, String rawPassword, String providerType) { + return create(tenantId, loginId, email, rawPassword, providerType, false); + } + + @Transactional + public UserAccountView create( + TenantId tenantId, + String loginId, + String email, + String rawPassword, + String providerType, + boolean mustChangePassword + ) { if (repository.findByTenantIdAndLoginId(tenantId.value(), loginId).isPresent()) { throw new IllegalStateException("User already exists: tenant=" + tenantId + " loginId=" + loginId); } @@ -55,6 +67,7 @@ public UserAccountView create(TenantId tenantId, String loginId, String email, S now, now ); + entity.setMustChangePassword(mustChangePassword); repository.save(entity); eventPublisher.publishEvent(new UserAccountCreatedEvent(UserId.of(id), tenantId, loginId, now)); return toView(entity); diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/SelfServicePasswordService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/SelfServicePasswordService.java new file mode 100644 index 0000000..d49f23d --- /dev/null +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/SelfServicePasswordService.java @@ -0,0 +1,62 @@ +package kr.devslab.kit.identity.core.service; + +import java.time.Clock; +import java.time.Instant; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.AccountLoginException; +import kr.devslab.kit.identity.LoginFailureReason; +import kr.devslab.kit.identity.PasswordHasher; +import kr.devslab.kit.identity.core.entity.PlatformUserAccountEntity; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import org.springframework.transaction.annotation.Transactional; + +/** + * Self-service password change for the currently authenticated account. + * + *

Distinct from {@link PlatformUserAccountAdminService#resetPassword} + * (an administrator resetting someone else's password with no + * knowledge of the old one). Here the caller proves ownership by supplying + * the current password, and a successful change also clears the + * {@code must_change_password} flag — this is the endpoint that releases a + * bootstrap admin from the forced-rotation gate (ADR 0001). + */ +public class SelfServicePasswordService { + + private final JpaPlatformUserAccountRepository repository; + private final PasswordHasher passwordHasher; + private final Clock clock; + + public SelfServicePasswordService( + JpaPlatformUserAccountRepository repository, + PasswordHasher passwordHasher, + Clock clock + ) { + this.repository = repository; + this.passwordHasher = passwordHasher; + this.clock = clock; + } + + /** + * Verify {@code oldRawPassword} against the stored hash, then store + * {@code newRawPassword} and clear the forced-rotation flag. + * + * @throws AccountLoginException with {@link LoginFailureReason#UNKNOWN_USER} + * if the id no longer resolves (e.g. account deleted mid-session), + * or {@link LoginFailureReason#BAD_CREDENTIALS} if the supplied + * current password does not match. + */ + @Transactional + public void changePassword(UserId id, String oldRawPassword, String newRawPassword) { + PlatformUserAccountEntity account = repository.findById(id.value()) + .orElseThrow(() -> new AccountLoginException(LoginFailureReason.UNKNOWN_USER)); + + if (account.getPasswordHash() == null + || !passwordHasher.matches(oldRawPassword, account.getPasswordHash())) { + throw new AccountLoginException(LoginFailureReason.BAD_CREDENTIALS); + } + + account.setPasswordHash(passwordHasher.hash(newRawPassword)); + account.setMustChangePassword(false); + account.setUpdatedAt(Instant.now(clock)); + } +} diff --git a/devslab-kit-identity-core/src/main/resources/db/migration/V11__platform_user_account_must_change_password.sql b/devslab-kit-identity-core/src/main/resources/db/migration/V11__platform_user_account_must_change_password.sql new file mode 100644 index 0000000..eddebd8 --- /dev/null +++ b/devslab-kit-identity-core/src/main/resources/db/migration/V11__platform_user_account_must_change_password.sql @@ -0,0 +1,2 @@ +ALTER TABLE platform_user_account + ADD COLUMN IF NOT EXISTS must_change_password boolean NOT NULL DEFAULT false; diff --git a/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java b/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java new file mode 100644 index 0000000..9fdc3d1 --- /dev/null +++ b/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/JjwtAuthTokenServiceTest.java @@ -0,0 +1,58 @@ +package kr.devslab.kit.identity.core.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Set; +import java.util.UUID; +import kr.devslab.kit.core.id.PublicId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.AuthToken; +import kr.devslab.kit.identity.CurrentUser; +import kr.devslab.kit.identity.UserStatus; +import org.junit.jupiter.api.Test; + +class JjwtAuthTokenServiceTest { + + private static final String SECRET = "test-only-32byte-jwt-signing-key!!"; + private final Clock clock = Clock.fixed(Instant.parse("2026-05-31T00:00:00Z"), ZoneOffset.UTC); + private final JjwtAuthTokenService service = + new JjwtAuthTokenService(SECRET, Duration.ofHours(8), "devslab-kit-test", clock); + + private CurrentUser user(boolean mustChangePassword) { + UUID id = UUID.randomUUID(); + return new CurrentUser( + UserId.of(id), + PublicId.of("usr_" + id), + TenantId.of("default"), + "admin", + UserStatus.ACTIVE, + Set.of("PLATFORM_ADMIN"), + mustChangePassword + ); + } + + @Test + void roundTripsMustChangePasswordTrue() { + AuthToken token = service.issue(user(true)); + + CurrentUser parsed = service.parse(token.value()).orElseThrow(); + + assertThat(parsed.mustChangePassword()).isTrue(); + assertThat(parsed.roles()).containsExactly("PLATFORM_ADMIN"); + assertThat(parsed.loginId()).isEqualTo("admin"); + } + + @Test + void roundTripsMustChangePasswordFalse() { + AuthToken token = service.issue(user(false)); + + CurrentUser parsed = service.parse(token.value()).orElseThrow(); + + assertThat(parsed.mustChangePassword()).isFalse(); + } +} diff --git a/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/SelfServicePasswordServiceTest.java b/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/SelfServicePasswordServiceTest.java new file mode 100644 index 0000000..d3fa2bb --- /dev/null +++ b/devslab-kit-identity-core/src/test/java/kr/devslab/kit/identity/core/service/SelfServicePasswordServiceTest.java @@ -0,0 +1,93 @@ +package kr.devslab.kit.identity.core.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.AccountLoginException; +import kr.devslab.kit.identity.LoginFailureReason; +import kr.devslab.kit.identity.PasswordHasher; +import kr.devslab.kit.identity.UserStatus; +import kr.devslab.kit.identity.core.entity.PlatformUserAccountEntity; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SelfServicePasswordServiceTest { + + private final PasswordHasher passwordHasher = new BCryptPasswordHasher(); + private final Clock clock = Clock.fixed(Instant.parse("2026-05-31T00:00:00Z"), ZoneOffset.UTC); + + @Mock + private JpaPlatformUserAccountRepository repository; + + private SelfServicePasswordService service; + private UUID userId; + private PlatformUserAccountEntity account; + + @BeforeEach + void setUp() { + service = new SelfServicePasswordService(repository, passwordHasher, clock); + userId = UUID.randomUUID(); + account = new PlatformUserAccountEntity( + userId, + "usr_" + userId, + "default", + "admin", + "admin@example.com", + passwordHasher.hash("oldpass"), + UserStatus.ACTIVE, + false, + "LOCAL", + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-01T00:00:00Z") + ); + account.setMustChangePassword(true); + lenient().when(repository.findById(userId)).thenReturn(Optional.of(account)); + } + + @Test + void changesPasswordAndClearsForcedRotationFlag() { + service.changePassword(UserId.of(userId), "oldpass", "brand-new-password"); + + assertThat(passwordHasher.matches("brand-new-password", account.getPasswordHash())).isTrue(); + assertThat(passwordHasher.matches("oldpass", account.getPasswordHash())).isFalse(); + assertThat(account.isMustChangePassword()).isFalse(); + assertThat(account.getUpdatedAt()).isEqualTo(Instant.parse("2026-05-31T00:00:00Z")); + } + + @Test + void rejectsWrongCurrentPasswordWithoutMutating() { + String originalHash = account.getPasswordHash(); + + assertThatThrownBy(() -> service.changePassword(UserId.of(userId), "WRONG", "brand-new-password")) + .isInstanceOf(AccountLoginException.class) + .extracting(ex -> ((AccountLoginException) ex).reason()) + .isEqualTo(LoginFailureReason.BAD_CREDENTIALS); + + assertThat(account.getPasswordHash()).isEqualTo(originalHash); + assertThat(account.isMustChangePassword()).isTrue(); + } + + @Test + void throwsUnknownUserWhenAccountMissing() { + UUID missing = UUID.randomUUID(); + when(repository.findById(missing)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.changePassword(UserId.of(missing), "x", "brand-new-password")) + .isInstanceOf(AccountLoginException.class) + .extracting(ex -> ((AccountLoginException) ex).reason()) + .isEqualTo(LoginFailureReason.UNKNOWN_USER); + } +} diff --git a/devslab-kit-sample-app/build.gradle.kts b/devslab-kit-sample-app/build.gradle.kts index fb73f6e..75490e8 100644 --- a/devslab-kit-sample-app/build.gradle.kts +++ b/devslab-kit-sample-app/build.gradle.kts @@ -36,3 +36,14 @@ dependencies { testCompileOnly("org.projectlombok:lombok") testAnnotationProcessor("org.projectlombok:lombok") } + +// Pin the main class explicitly. Auto-detection (ResolveMainClassName) does not +// resolve it in this multi-module setup where the `java` plugin is applied to +// subprojects from the root after the Spring Boot plugin, leaving main-class +// resolution — and therefore `./gradlew build` — to fail. The GraalVM native +// plugin also pulls in the `application` plugin, whose `startScripts` task reads +// `application.mainClass`. Setting it here feeds bootJar/bootRun (via +// convention), startScripts, and nativeCompile. +application { + mainClass.set("kr.devslab.kit.sample.SampleApplication") +} diff --git a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java index 20d1c13..19d3ad3 100644 --- a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java +++ b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java @@ -1,55 +1,25 @@ package kr.devslab.kit.sample; -import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; -import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; -import kr.devslab.kit.access.core.service.PermissionAdminService; -import kr.devslab.kit.access.core.service.RoleAdminService; -import kr.devslab.kit.access.core.service.RolePermissionService; -import kr.devslab.kit.access.core.service.UserRoleService; -import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; -import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; -import kr.devslab.kit.tenant.TenantService; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.AutoConfigurationPackage; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +/** + * Sample application that verifies the devslab-kit starter end-to-end. + * + *

First-boot provisioning (a default tenant + admin user with the + * {@code PLATFORM_ADMIN} role and the full {@code admin.*} permission set) is + * handled by the starter's own bootstrap runner — see ADR 0001 and the + * {@code devslab.kit.bootstrap.*} block in {@code application.yaml}. The sample + * app no longer ships its own seed runner. + */ @SpringBootApplication(scanBasePackages = "kr.devslab.kit") @AutoConfigurationPackage(basePackages = "kr.devslab.kit") @EnableJpaRepositories(basePackages = "kr.devslab.kit") -@EnableConfigurationProperties(SampleSeedProperties.class) public class SampleApplication { public static void main(String[] args) { SpringApplication.run(SampleApplication.class, args); } - - @Bean - SampleSeedRunner sampleSeedRunner( - SampleSeedProperties props, - TenantService tenantService, - PermissionAdminService permissionAdminService, - JpaPlatformPermissionRepository permissionRepository, - RoleAdminService roleAdminService, - JpaPlatformRoleRepository roleRepository, - RolePermissionService rolePermissionService, - PlatformUserAccountAdminService userAdminService, - JpaPlatformUserAccountRepository userRepository, - UserRoleService userRoleService - ) { - return new SampleSeedRunner( - props, - tenantService, - permissionAdminService, - permissionRepository, - roleAdminService, - roleRepository, - rolePermissionService, - userAdminService, - userRepository, - userRoleService - ); - } } diff --git a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedProperties.java b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedProperties.java deleted file mode 100644 index 4936a0c..0000000 --- a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedProperties.java +++ /dev/null @@ -1,26 +0,0 @@ -package kr.devslab.kit.sample; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** - * Settings for {@link SampleSeedRunner}. Only meaningful in the sample - * application — real consumers seed their own data however they like. - */ -@ConfigurationProperties(prefix = "sample.seed") -public class SampleSeedProperties { - - /** Disable for production-like environments where seed data is unwanted. */ - private boolean enabled = true; - private String adminLoginId = "admin"; - private String adminPassword = "admin"; - private String adminEmail = "admin@example.com"; - - public boolean isEnabled() { return enabled; } - public void setEnabled(boolean enabled) { this.enabled = enabled; } - public String getAdminLoginId() { return adminLoginId; } - public void setAdminLoginId(String adminLoginId) { this.adminLoginId = adminLoginId; } - public String getAdminPassword() { return adminPassword; } - public void setAdminPassword(String adminPassword) { this.adminPassword = adminPassword; } - public String getAdminEmail() { return adminEmail; } - public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; } -} diff --git a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedRunner.java b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedRunner.java deleted file mode 100644 index 8eff875..0000000 --- a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedRunner.java +++ /dev/null @@ -1,180 +0,0 @@ -package kr.devslab.kit.sample; - -import java.util.List; -import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; -import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; -import kr.devslab.kit.access.core.service.PermissionAdminService; -import kr.devslab.kit.access.core.service.RoleAdminService; -import kr.devslab.kit.access.core.service.RolePermissionService; -import kr.devslab.kit.access.core.service.UserRoleService; -import kr.devslab.kit.core.id.PermissionId; -import kr.devslab.kit.core.id.RoleId; -import kr.devslab.kit.core.id.TenantId; -import kr.devslab.kit.core.id.UserId; -import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; -import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; -import kr.devslab.kit.tenant.TenantMetadata; -import kr.devslab.kit.tenant.TenantMode; -import kr.devslab.kit.tenant.TenantService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.transaction.annotation.Transactional; - -/** - * Idempotent first-boot seed for the sample app. - * - *

Provisions just enough for the admin UI to be usable end-to-end against - * this backend: - * - *

    - *
  • One tenant: {@code default}.
  • - *
  • One role: {@code PLATFORM_ADMIN} bound to that tenant.
  • - *
  • The {@code admin.*} permission set covering every admin-api endpoint.
  • - *
  • One user: {@code admin / admin} (override via {@code SAMPLE_SEED_ADMIN_*}), - * holding {@code PLATFORM_ADMIN}.
  • - *
- * - *

Every step is idempotent — repeated boots find each entity by code / - * loginId and skip the create, so re-running against an already-seeded - * database is a no-op. - * - *

Disable with {@code sample.seed.enabled=false} for production-like - * runs where seed data would be unwanted. - */ -public class SampleSeedRunner implements ApplicationRunner { - - private static final Logger log = LoggerFactory.getLogger(SampleSeedRunner.class); - - private static final String DEFAULT_TENANT_ID = "default"; - private static final String ADMIN_ROLE_CODE = "PLATFORM_ADMIN"; - private static final String ADMIN_ROLE_NAME = "Platform Admin"; - - private static final List ADMIN_PERMISSIONS = List.of( - new PermissionSeed("admin.user.read", "Read user accounts via /admin/api/v1/users"), - new PermissionSeed("admin.user.write", "Create / update / delete user accounts"), - new PermissionSeed("admin.role.read", "Read roles and their permission grants"), - new PermissionSeed("admin.role.write", "Create / rename / delete roles and grant permissions"), - new PermissionSeed("admin.permission.read", "Read permission catalogue"), - new PermissionSeed("admin.permission.write", "Create / update / delete permissions"), - new PermissionSeed("admin.group.read", "Read groups and memberships"), - new PermissionSeed("admin.group.write", "Create / rename / delete groups and manage memberships"), - new PermissionSeed("admin.menu.read", "Read menu tree"), - new PermissionSeed("admin.menu.write", "Create / update / delete menu items"), - new PermissionSeed("admin.tenant.read", "Read tenants"), - new PermissionSeed("admin.tenant.write", "Create / rename / change status / delete tenants"), - new PermissionSeed("admin.policy.test", "Run policy dry-run via /admin/api/v1/policies/test"), - new PermissionSeed("admin.audit.read", "Read audit logs with filters + paging"), - new PermissionSeed("admin.diagnostics.run", "Run diagnostics probes (login-test, permission-check, menu-visibility)"), - new PermissionSeed("admin.settings.read", "Read DevslabKitProperties via /admin/api/v1/settings") - ); - - private final SampleSeedProperties props; - private final TenantService tenantService; - private final PermissionAdminService permissionAdminService; - private final JpaPlatformPermissionRepository permissionRepository; - private final RoleAdminService roleAdminService; - private final JpaPlatformRoleRepository roleRepository; - private final RolePermissionService rolePermissionService; - private final PlatformUserAccountAdminService userAdminService; - private final JpaPlatformUserAccountRepository userRepository; - private final UserRoleService userRoleService; - - public SampleSeedRunner( - SampleSeedProperties props, - TenantService tenantService, - PermissionAdminService permissionAdminService, - JpaPlatformPermissionRepository permissionRepository, - RoleAdminService roleAdminService, - JpaPlatformRoleRepository roleRepository, - RolePermissionService rolePermissionService, - PlatformUserAccountAdminService userAdminService, - JpaPlatformUserAccountRepository userRepository, - UserRoleService userRoleService - ) { - this.props = props; - this.tenantService = tenantService; - this.permissionAdminService = permissionAdminService; - this.permissionRepository = permissionRepository; - this.roleAdminService = roleAdminService; - this.roleRepository = roleRepository; - this.rolePermissionService = rolePermissionService; - this.userAdminService = userAdminService; - this.userRepository = userRepository; - this.userRoleService = userRoleService; - } - - @Override - @Transactional - public void run(ApplicationArguments args) { - if (!props.isEnabled()) { - log.info("[sample-seed] disabled via sample.seed.enabled=false — skipping"); - return; - } - - TenantId tenant = ensureTenant(); - RoleId adminRole = ensureAdminRole(tenant); - ensureAdminPermissionsAndGrants(adminRole); - UserId adminUser = ensureAdminUser(tenant); - userRoleService.assign(adminUser, adminRole, tenant); // idempotent - - log.info("[sample-seed] complete: tenant={} role={} user={} (login as {} / {})", - tenant.value(), ADMIN_ROLE_CODE, adminUser.value(), - props.getAdminLoginId(), props.getAdminPassword()); - } - - private TenantId ensureTenant() { - TenantId id = TenantId.of(DEFAULT_TENANT_ID); - if (tenantService.findById(id).isPresent()) { - return id; - } - TenantMetadata created = tenantService.create(id, "Default Tenant", TenantMode.SINGLE); - log.info("[sample-seed] created tenant {}", created.id().value()); - return id; - } - - private RoleId ensureAdminRole(TenantId tenant) { - return roleRepository.findByTenantIdAndCode(tenant.value(), ADMIN_ROLE_CODE) - .map(entity -> RoleId.of(entity.getId())) - .orElseGet(() -> { - var role = roleAdminService.create(tenant, ADMIN_ROLE_CODE, ADMIN_ROLE_NAME); - log.info("[sample-seed] created role {}", ADMIN_ROLE_CODE); - return role.id(); - }); - } - - private void ensureAdminPermissionsAndGrants(RoleId adminRole) { - for (PermissionSeed perm : ADMIN_PERMISSIONS) { - PermissionId permId = permissionRepository.findByCode(perm.code()) - .map(entity -> PermissionId.of(entity.getId())) - .orElseGet(() -> { - permissionAdminService.create(perm.code(), perm.description()); - log.info("[sample-seed] created permission {}", perm.code()); - return permissionRepository.findByCode(perm.code()) - .map(e -> PermissionId.of(e.getId())) - .orElseThrow(); - }); - rolePermissionService.grant(adminRole, permId); // idempotent - } - } - - private UserId ensureAdminUser(TenantId tenant) { - return userRepository.findByTenantIdAndLoginId(tenant.value(), props.getAdminLoginId()) - .map(entity -> UserId.of(entity.getId())) - .orElseGet(() -> { - var view = userAdminService.create( - tenant, - props.getAdminLoginId(), - props.getAdminEmail(), - props.getAdminPassword(), - "LOCAL" - ); - log.info("[sample-seed] created user {}", view.loginId()); - return view.id(); - }); - } - - private record PermissionSeed(String code, String description) { - } -} diff --git a/devslab-kit-sample-app/src/main/resources/application.yaml b/devslab-kit-sample-app/src/main/resources/application.yaml index 4c9ddc6..ae079a6 100644 --- a/devslab-kit-sample-app/src/main/resources/application.yaml +++ b/devslab-kit-sample-app/src/main/resources/application.yaml @@ -42,12 +42,19 @@ devslab: enabled: true cache-ttl: ${DEVSLAB_MENU_CACHE_TTL:PT5M} -# Sample-app-only seed runner: provisions a default tenant + admin user with -# PLATFORM_ADMIN role + admin.* permissions on first boot, so the admin UI -# can log in immediately for end-to-end testing. Disable in production. -sample: - seed: - enabled: ${SAMPLE_SEED_ENABLED:true} - admin-login-id: ${SAMPLE_SEED_ADMIN_LOGIN_ID:admin} - admin-password: ${SAMPLE_SEED_ADMIN_PASSWORD:admin} - admin-email: ${SAMPLE_SEED_ADMIN_EMAIL:admin@example.com} + # First-admin bootstrap (ADR 0001). The starter's own runner provisions a + # default tenant + PLATFORM_ADMIN role + admin.* permissions + admin user on + # first boot, so the admin UI can log in immediately for end-to-end testing. + # + # This stands in for a local-dev profile: a fixed admin/admin with the + # forced password change turned OFF so you log straight in. In staging / + # production, inject DEVSLAB_BOOTSTRAP_ADMIN_PASSWORD (or leave it blank for + # a random one logged once) and set must-change-password to true — or set + # enabled=false and provision the first admin out-of-band. + bootstrap: + enabled: ${DEVSLAB_BOOTSTRAP_ENABLED:true} + tenant-id: ${DEVSLAB_BOOTSTRAP_TENANT_ID:default} + admin-login-id: ${DEVSLAB_BOOTSTRAP_ADMIN_LOGIN_ID:admin} + admin-password: ${DEVSLAB_BOOTSTRAP_ADMIN_PASSWORD:admin} + admin-email: ${DEVSLAB_BOOTSTRAP_ADMIN_EMAIL:admin@example.com} + must-change-password: ${DEVSLAB_BOOTSTRAP_MUST_CHANGE_PASSWORD:false} diff --git a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/SampleApplicationTests.java b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/SampleApplicationTests.java index 02a4b40..f2d7d4d 100644 --- a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/SampleApplicationTests.java +++ b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/SampleApplicationTests.java @@ -6,6 +6,8 @@ import kr.devslab.kit.audit.AuditEventPublisher; import kr.devslab.kit.identity.CurrentUserProvider; import kr.devslab.kit.identity.PasswordHasher; +import kr.devslab.kit.identity.core.entity.PlatformUserAccountEntity; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; import kr.devslab.kit.identity.core.service.LocalLoginService; import kr.devslab.kit.menu.MenuProvider; import kr.devslab.kit.tenant.TenantContextHolder; @@ -43,6 +45,9 @@ class SampleApplicationTests { @Autowired private AuditEventPublisher auditEventPublisher; + @Autowired + private JpaPlatformUserAccountRepository userAccountRepository; + @Test void contextLoads() { assertThat(tenantResolver).isNotNull(); @@ -66,4 +71,19 @@ void bcryptPasswordHasherRoundTrips() { assertThat(passwordHasher.matches("hunter2", hash)).isTrue(); assertThat(passwordHasher.matches("wrong", hash)).isFalse(); } + + @Test + void bootstrapProvisionsAdminUser() { + // The starter's bootstrap runner (devslab.kit.bootstrap.enabled=true in + // application.yaml) provisions admin/admin in the default tenant on + // first boot, with the forced-rotation flag OFF for this local-dev-style + // config. Proves the runner wired up and executed end-to-end. + PlatformUserAccountEntity admin = userAccountRepository + .findByTenantIdAndLoginId("default", "admin") + .orElseThrow(); + + assertThat(admin.getStatus().name()).isEqualTo("ACTIVE"); + assertThat(admin.isMustChangePassword()).isFalse(); + assertThat(passwordHasher.matches("admin", admin.getPasswordHash())).isTrue(); + } } diff --git a/docs/adr/0001-bootstrap-admin.ko.md b/docs/adr/0001-bootstrap-admin.ko.md index 32dfbb5..9c369e7 100644 --- a/docs/adr/0001-bootstrap-admin.ko.md +++ b/docs/adr/0001-bootstrap-admin.ko.md @@ -1,8 +1,13 @@ # ADR 0001 — 환경별 최초 관리자 부트스트랩 -- **상태:** 제안(Proposed) -- **날짜:** 2026-05-30 +- **상태:** 수락(Accepted) +- **날짜:** 2026-05-30 (2026-05-31 수락) - **언어:** [English](0001-bootstrap-admin.md) · [한국어](0001-bootstrap-admin.ko.md) +- **구현:** 최초 관리자 부트스트랩 기능 — `must_change_password`(V11) + + self-service `POST /admin/api/v1/auth/change-password` + + `BootstrapAutoConfiguration` / `DevslabKitBootstrapRunner`, 그리고 `sample-app`을 + 기존 시드 러너에서 전환. admin-ui 강제변경 가드 + 화면은 `devslab-kit-admin-ui` + 후속 작업으로 처리. ## 배경 diff --git a/docs/adr/0001-bootstrap-admin.md b/docs/adr/0001-bootstrap-admin.md index c88f678..972982a 100644 --- a/docs/adr/0001-bootstrap-admin.md +++ b/docs/adr/0001-bootstrap-admin.md @@ -1,8 +1,13 @@ # ADR 0001 — First-admin bootstrap across environments -- **Status:** Proposed -- **Date:** 2026-05-30 +- **Status:** Accepted +- **Date:** 2026-05-30 (accepted 2026-05-31) - **Languages:** [English](0001-bootstrap-admin.md) · [한국어](0001-bootstrap-admin.ko.md) +- **Implemented by:** the first-admin bootstrap feature — `must_change_password` + (V11) + self-service `POST /admin/api/v1/auth/change-password` + + `BootstrapAutoConfiguration` / `DevslabKitBootstrapRunner`, with the + `sample-app` switched off its old seed runner. The admin-ui forced-change + guard + screen lands as a follow-up in `devslab-kit-admin-ui`. ## Context From b14e432a29a70dde2953444c44f5d74dc24c1a0d Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 09:41:25 +0900 Subject: [PATCH 3/3] fix(sample-app): drop the unnecessary main-class pin that broke CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- devslab-kit-sample-app/build.gradle.kts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/devslab-kit-sample-app/build.gradle.kts b/devslab-kit-sample-app/build.gradle.kts index 75490e8..fb73f6e 100644 --- a/devslab-kit-sample-app/build.gradle.kts +++ b/devslab-kit-sample-app/build.gradle.kts @@ -36,14 +36,3 @@ dependencies { testCompileOnly("org.projectlombok:lombok") testAnnotationProcessor("org.projectlombok:lombok") } - -// Pin the main class explicitly. Auto-detection (ResolveMainClassName) does not -// resolve it in this multi-module setup where the `java` plugin is applied to -// subprojects from the root after the Spring Boot plugin, leaving main-class -// resolution — and therefore `./gradlew build` — to fail. The GraalVM native -// plugin also pulls in the `application` plugin, whose `startScripts` task reads -// `application.mainClass`. Setting it here feeds bootJar/bootRun (via -// convention), startScripts, and nativeCompile. -application { - mainClass.set("kr.devslab.kit.sample.SampleApplication") -}