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.