diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3ac8271 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: Release to Maven Central + +# Cutting a release is a tag push: `git tag v0.1.0 && git push origin v0.1.0`. +# This publishes every library module to Maven Central (Central Portal, signed +# with the org GPG key) and opens a GitHub Release with generated notes. +# +# Requires these org-level secrets (already configured on devslab-kr): +# MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD — Central Portal token +# SIGNING_KEY / SIGNING_KEY_ID / SIGNING_KEY_PASSWORD — ASCII-armored GPG key +# If the org secrets are scoped to "selected repositories", grant this repo access. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + publish: + name: Publish to Maven Central + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 21 (GraalVM CE) + uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm-community' + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Publish to Maven Central + run: ./gradlew publishToMavenCentral --no-daemon --no-configuration-cache --stacktrace + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/CHANGELOG.ko.md b/CHANGELOG.ko.md new file mode 100644 index 0000000..61bdd2b --- /dev/null +++ b/CHANGELOG.ko.md @@ -0,0 +1,83 @@ +# 변경 이력 (Changelog) + +이 프로젝트의 주요 변경 사항을 기록합니다. + +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[유의적 버전(SemVer)](https://semver.org/lang/ko/)을 준수합니다. + +라이브러리 메이저는 Spring Boot 메이저와 정렬됩니다: `4.x.y`는 Spring Boot 4.x를 대상으로 합니다. + +English: [CHANGELOG.md](CHANGELOG.md) + +## [Unreleased] + +### Added +- **Maven Central 배포** — 모든 라이브러리 모듈을 vanniktech maven-publish 플러그인으로 + Maven Central에 배포합니다 (Central Portal, 서명, `v*` 태그 시 자동 릴리스). + `release.yml`이 아티팩트를 배포하고 GitHub Release를 생성합니다. +- **플러그형 캐시** (ADR 0002) — `devslab.kit.cache.type` = `in-memory` / `redis` / + `none`. Redis 백엔드가 JSON 직렬화를 직접 책임지므로 (`Serializable` 불필요, 직렬화기 + 설정 불필요) 사용자별 메뉴 캐시도 자체 맵 대신 이 공유 캐시 매니저를 사용합니다. +- **최초 관리자 부트스트랩** (ADR 0001) — opt-in, 프로퍼티 기반 프로비저닝으로 빈 데이터베이스가 + 영구적인 백도어 없이 사용 가능한 대시보드에 도달합니다. `devslab.kit.bootstrap.*`(기본 OFF)가 + 첫 부팅 시 테넌트, 전체 `admin.*` 권한 세트를 가진 `PLATFORM_ADMIN` 역할, 관리자 사용자 1명을 + 멱등하게 생성합니다. 빈 비밀번호는 강력한 랜덤 비밀번호를 생성해 한 번 로깅하며, prod 안전장치는 + `prod`/`production` 프로파일에서 약한 비밀번호를 거부합니다. +- **비밀번호 강제 변경** — 사용자 계정의 `must_change_password` 플래그(`V11`)를 `CurrentUser`, + JWT 클레임, 로그인 응답으로 노출합니다. 셀프 서비스 `POST /admin/api/v1/auth/change-password`가 + 이전 비밀번호를 검증하고 새 비밀번호를 설정한 뒤 플래그를 지우고 토큰을 재발급합니다. +- **부트스트랩 상태 프로브** — 인증 없는 `GET /admin/api/v1/bootstrap/status`가 + `{ initialized: boolean }`을 반환합니다. 향후 가이드형 최초 실행 / 설정 마법사의 분기점입니다 + (ADR 0001 §6). + +### Fixed +- **JWT 검증이 주입된 `Clock`을 사용하도록 수정** — `JjwtAuthTokenService.parse()`가 토큰 만료를 + 주입된 시계가 아니라 실제 시스템 시계로 검증하고 있었습니다. 이로 인해 고정 시계로는 검증을 + 테스트할 수 없었고 `issue()`와 비대칭이었습니다. 운영 동작은 그대로입니다 (런타임은 양쪽 모두 + `Clock.systemUTC()` 사용). + +### Changed +- `sample-app`이 `SampleSeedRunner`를 끄고 스타터의 `devslab.kit.bootstrap.*` 러너로 + 전환했습니다 (로컬 개발 형태: `admin/admin`, `must-change-password=false`). + +### Added (초기 스캐폴드) +- 초기 프로젝트 스캐폴드 (Spring Boot 4 + Java 21 + Gradle). +- 기본 의존성: Spring Web MVC, Spring Security, Spring Data JPA, Spring Data Redis, + Flyway (PostgreSQL), Spring Boot Actuator, GraalVM Native, Testcontainers (PostgreSQL + Redis), + Docker Compose 지원. +- 기본 패키지 `kr.devslab.kit`. +- Gradle 멀티모듈 분리 (계획 문서 §5): + `devslab-kit-core`, `-{identity,access,tenant,menu,audit}-{api,core}`, + `-autoconfigure`, `-spring-boot-starter`, `-sample-app`. +- 코어 값 객체: `UserId`, `TenantId`, `RoleId`, `PermissionId`, `MenuId`, `PublicId`, + `DevslabKitException`. +- Tenant: `TenantContext`, `TenantContextHolder`, `TenantResolver`, `TenantMode` (api) + + `DefaultTenantContextHolder`, `FixedTenantResolver` (core). AutoConfig override 패턴을 + 증명하는 데 사용한, 완전히 배선된 첫 수직 슬라이스. +- Identity (api): `CurrentUser`, `CurrentUserProvider`, `UserStatus`, `LoginCommand`, + `LoginResult`, `UserAccountView`, `PasswordHasher`, `LoginFailureReason`, + `AccountLoginException`, `LoginSucceededEvent`, `LoginFailedEvent`, + `UserAccountCreatedEvent`. + Identity (core): `PlatformUserAccountEntity` + `JpaPlatformUserAccountRepository`, + `BCryptPasswordHasher`, `LocalLoginService`, `PlatformUserAccountService`, + `DefaultCurrentUserProvider`, `V1__platform_user_account.sql`. +- Access (api): `Permission`, `Role`, `PermissionChecker`, `PermissionDeniedException`. + Access (core): `Platform{Role,Permission,UserRole,RolePermission}Entity` + Jpa 리포지토리, + `UserRoleService`, `RolePermissionService`, `DefaultPermissionChecker`, `V2__platform_access.sql`. +- Menu (api): `MenuItem`, `MenuTree`, `MenuProvider`. + Menu (core): `PlatformMenuEntity` + `JpaPlatformMenuRepository`, `MenuTreeBuilder`, + `PermissionBasedMenuFilter`, `DefaultMenuProvider`, `V3__platform_menu.sql`. +- Audit (api): `AuditEvent`, `AuditActor`, `AuditAction`, `AuditTarget`, `AuditEventPublisher`. + Audit (core): `PlatformAuditLogEntity` + `JpaPlatformAuditLogRepository`, `AuditLogService` + (Jackson 직렬화 메타데이터), `DefaultAuditEventPublisher`, `V4__platform_audit_log.sql`. +- `DevslabKitProperties` (`devslab.kit.*` 접두사) + `@ConditionalOnMissingBean` override를 갖춘 + 5개 `AutoConfiguration`: `Tenant`, `Identity`, `Access`, `Menu`, `Audit`. +- `devslab-kit-sample-app`이 8개 스타터 빈(`TenantResolver`, `TenantContextHolder`, + `CurrentUserProvider`, `PasswordHasher`, `LocalLoginService`, `PermissionChecker`, + `MenuProvider`, `AuditEventPublisher`)과 BCrypt 왕복을 스모크 테스트합니다. + +### Notes +- 아직 `0.1.0` 이전 스캐폴드이며, `0.1.0` 전에 공개 표면이 바뀔 수 있습니다. +- SB 4.1-SNAPSHOT / Java 25 대신 Spring Boot 4.0.6(릴리스)와 Java 21을 대상으로 합니다. + SB SNAPSHOT + Java 25 조합이 일부 환경에서 IntelliJ Gradle 통합을 깨뜨려, 릴리스 버전으로 + 고정해 import 경로를 예측 가능하게 유지합니다. SB 4.1.x 릴리스가 나오면 Java 25를 재검토합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index d97ca4e..1b350cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot 4.x. +한국어: [CHANGELOG.ko.md](CHANGELOG.ko.md) + ## [Unreleased] ### Added +- **Maven Central publishing** — every library module ships to Maven Central via + the vanniktech maven-publish plugin (Central Portal, signed, auto-release on a + `v*` tag). `release.yml` publishes the artifacts and opens a GitHub Release. +- **Pluggable cache** (ADR 0002) — `devslab.kit.cache.type` = `in-memory` / + `redis` / `none`. The Redis backend owns JSON serialization (no `Serializable`, + no serializer wiring), and the per-user menu cache now rides this shared cache + manager instead of its own map. - **First-admin bootstrap** (ADR 0001) — opt-in, property-driven provisioning so a fresh database can reach a usable dashboard without a permanent backdoor. `devslab.kit.bootstrap.*` (OFF by default) idempotently creates a tenant, a @@ -25,6 +34,13 @@ The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot returning `{ initialized: boolean }`, the branch point for a future guided first-run / setup wizard (ADR 0001 §6). +### Fixed +- **JWT validation now honours the injected `Clock`** — `JjwtAuthTokenService.parse()` + validated token expiry against the real system clock instead of the injected one, + making validation untestable with a fixed clock and asymmetric with `issue()`. + Production behaviour is unchanged (the runtime uses `Clock.systemUTC()` on both + paths). + ### Changed - `sample-app` switched off its `SampleSeedRunner` onto the starter's `devslab.kit.bootstrap.*` runner (local-dev shape: `admin/admin`, diff --git a/build.gradle.kts b/build.gradle.kts index 19ed0bc..315eae2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,19 @@ +import com.vanniktech.maven.publish.MavenPublishBaseExtension + +// vanniktech maven-publish on the buildscript classpath so this root script can +// `import` its extension type and configure publishing for the library modules. +// (Declaring it `apply false` in plugins {} does not reliably expose the classes +// to the root script's compilation under Gradle 9.x — buildscript {} does.) +buildscript { + repositories { + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath("com.vanniktech:gradle-maven-publish-plugin:0.36.0") + } +} + plugins { id("org.springframework.boot") version "4.0.6" apply false id("io.spring.dependency-management") version "1.1.7" apply false @@ -6,14 +22,19 @@ plugins { allprojects { group = "kr.devslab" - version = "0.0.1-SNAPSHOT" + version = "0.1.0-SNAPSHOT" repositories { mavenCentral() } } +// Every module ships to Maven Central except the runnable reference app. +val nonPublishedModules = setOf("devslab-kit-sample-app") + subprojects { + val proj = this + apply(plugin = "java-library") apply(plugin = "io.spring.dependency-management") @@ -45,4 +66,28 @@ subprojects { tasks.withType().configureEach { useJUnitPlatform() } + + // --- Maven Central publishing for the library modules (not the sample app) --- + if (proj.name !in nonPublishedModules) { + apply(plugin = "com.vanniktech.maven.publish") + + configure { + // SONATYPE_HOST / SONATYPE_AUTOMATIC_RELEASE and the POM_* metadata + // come from gradle.properties. signAllPublications() enables GPG + // signing (with the org key) for releases and is a no-op for + // -SNAPSHOT, so publishToMavenLocal needs no key. + // + // Do NOT also set RELEASE_SIGNING_ENABLED in gradle.properties: it + // configures signing too, and combining the two double-sets the + // signing property, which Gradle 9 rejects as "value is final". + signAllPublications() + coordinates(proj.group.toString(), proj.name, proj.version.toString()) + pom { + name.set(proj.name) + // Each module sets its own `description` in build.gradle.kts; read + // it lazily since module scripts evaluate after this block runs. + description.set(proj.provider { proj.description ?: "devslab-kit :: ${proj.name}" }) + } + } + } } diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ea0c24f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,22 @@ +# --- Maven Central publishing (vanniktech maven-publish) --------------------- +# Publishes through the new Central Portal and auto-promotes the deployment, so a +# `v*` tag push releases without a manual "close + release" in the portal UI. +SONATYPE_HOST=CENTRAL_PORTAL +SONATYPE_AUTOMATIC_RELEASE=true + +# Shared POM metadata. Per-module / are set in build.gradle.kts +# (each module's own `description`). These apply to every published artifact. +POM_INCEPTION_YEAR=2026 +POM_URL=https://github.com/devslab-kr/devslab-kit + +POM_LICENSE_NAME=The Apache License, Version 2.0 +POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENSE_DIST=repo + +POM_DEVELOPER_ID=devslab-kr +POM_DEVELOPER_NAME=devslab +POM_DEVELOPER_URL=https://github.com/devslab-kr + +POM_SCM_URL=https://github.com/devslab-kr/devslab-kit +POM_SCM_CONNECTION=scm:git:git://github.com/devslab-kr/devslab-kit.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/devslab-kr/devslab-kit.git