From 46950105032c7699f5badd62fab75892d63ddfd3 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Wed, 3 Jun 2026 23:27:17 +0900 Subject: [PATCH 1/2] feat: config-driven RBAC seed (bootstrap.seed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devslab.kit.bootstrap.seed provisions starter permissions + roles (with grants) idempotently on boot, so consumers don't hand-create them in the console. Declare your domain permission codes and the roles that group them; the kit creates whatever is missing and grants it. - DevslabKitProperties.Bootstrap.Seed: permissions (list) + roles (map of code -> permission codes) - DevslabKitBootstrapRunner: seedDeclaredRbac() — idempotent, additive (never revokes/deletes); a permission referenced by a role is auto-created; permissions global, roles created in bootstrap tenant. Refactored shared ensurePermissionId/ensureRoleId helpers out of the admin bootstrap. - BootstrapSeedTests (sample-app, Testcontainers): declared permissions + roles + grants created, role-only permission auto-created, grants exact. - docs: configuration reference + bootstrap/access guides (EN/KO), CHANGELOG 0.5.0, version refs bumped to 0.5.0. Also corrected the tenant `jwt` resolver note in the configuration reference to "reserved, not yet shipped" (matches the tenancy guide). Verification: ./gradlew build BUILD SUCCESSFUL; BootstrapSeedTests 4/0/0; mkdocs build --strict clean. --- CHANGELOG.ko.md | 22 +++++ CHANGELOG.md | 24 +++++ README.ko.md | 4 +- README.md | 4 +- .../DevslabKitBootstrapRunner.java | 72 +++++++++++--- .../autoconfigure/DevslabKitProperties.java | 62 +++++++++++++ .../kit/sample/BootstrapSeedTests.java | 93 +++++++++++++++++++ docs/getting-started/installation.ko.md | 12 +-- docs/getting-started/installation.md | 12 +-- docs/getting-started/tutorial.ko.md | 2 +- docs/getting-started/tutorial.md | 2 +- docs/guides/access.ko.md | 4 + docs/guides/access.md | 5 + docs/guides/bootstrap.ko.md | 32 +++++++ docs/guides/bootstrap.md | 33 +++++++ docs/reference/configuration.ko.md | 27 +++++- docs/reference/configuration.md | 28 +++++- docs/roadmap.ko.md | 2 +- docs/roadmap.md | 2 +- 19 files changed, 403 insertions(+), 39 deletions(-) create mode 100644 devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/BootstrapSeedTests.java diff --git a/CHANGELOG.ko.md b/CHANGELOG.ko.md index 5bbc972..e01ec2f 100644 --- a/CHANGELOG.ko.md +++ b/CHANGELOG.ko.md @@ -11,6 +11,28 @@ English: [CHANGELOG.md](CHANGELOG.md) ## [Unreleased] +## [0.5.0] — 2026-06-03 + +### Added +- **설정 기반 RBAC 시드.** `devslab.kit.bootstrap.seed`가 스타터 권한·역할(부여 포함)을 부팅 시 + 멱등하게 프로비저닝해, consumer가 콘솔에서 일일이 만들 필요가 없습니다. 도메인 권한 코드와 + 그것을 묶는 역할을 선언하세요: + ```yaml + devslab: + kit: + bootstrap: + enabled: true + seed: + permissions: [tasks.read, tasks.write, tasks.update, tasks.delete] + roles: + viewer: [tasks.read] + editor: [tasks.read, tasks.write, tasks.update] + owner: [tasks.read, tasks.write, tasks.update, tasks.delete] + ``` + 추가형(additive)입니다 — 매 부팅 시 없는 것을 만들고 나열된 grant를 추가하되, 회수·삭제는 + 하지 않습니다. 역할이 참조하는 권한은 자동 생성됩니다. 권한은 전역, 역할은 + `bootstrap.tenant-id`에 생성됩니다. [부트스트랩 가이드](guides/bootstrap.md#seed) 참고. + ## [0.4.2] — 2026-06-03 ### Added diff --git a/CHANGELOG.md b/CHANGELOG.md index f90a5ce..23c998f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot ## [Unreleased] +## [0.5.0] — 2026-06-03 + +### Added +- **Config-driven RBAC seed.** `devslab.kit.bootstrap.seed` provisions starter + permissions and roles (with grants) idempotently on boot, so consumers don't have + to hand-create them in the console. Declare your domain's permission codes and the + roles that group them: + ```yaml + devslab: + kit: + bootstrap: + enabled: true + seed: + permissions: [tasks.read, tasks.write, tasks.update, tasks.delete] + roles: + viewer: [tasks.read] + editor: [tasks.read, tasks.write, tasks.update] + owner: [tasks.read, tasks.write, tasks.update, tasks.delete] + ``` + Additive only — every boot creates what's missing and adds the listed grants, but + never revokes or deletes; a permission referenced by a role is auto-created. + Permissions are global; roles are created in `bootstrap.tenant-id`. See the + [bootstrap guide](guides/bootstrap.md#seed). + ## [0.4.2] — 2026-06-03 ### Added diff --git a/README.ko.md b/README.ko.md index 3b93db4..dd13c8c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -63,7 +63,7 @@ **Gradle (Kotlin DSL)** ```kotlin -implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") +implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") ``` **Maven** @@ -72,7 +72,7 @@ implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") kr.devslab devslab-kit-spring-boot-starter - 0.4.2 + 0.5.0 ``` diff --git a/README.md b/README.md index ce9b847..cfa59ef 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ specific product's domain. **Gradle (Kotlin DSL)** ```kotlin -implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") +implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") ``` **Maven** @@ -74,7 +74,7 @@ implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") kr.devslab devslab-kit-spring-boot-starter - 0.4.2 + 0.5.0 ``` 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 index a7a350e..c2e4229 100644 --- 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 @@ -4,6 +4,7 @@ import java.util.Base64; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; @@ -152,6 +153,8 @@ && isProductionProfile() userRoleService.assign(adminUser, adminRole, tenant); // idempotent + seedDeclaredRbac(tenant); + log.info("[devslab-kit bootstrap] complete: tenant={} role={} user={}", tenant.value(), props.getRoleCode(), props.getAdminLoginId()); } @@ -167,27 +170,70 @@ private TenantId ensureTenant() { } private RoleId ensureAdminRole(TenantId tenant) { - return roleRepository.findByTenantIdAndCode(tenant.value(), props.getRoleCode()) + return ensureRoleId(tenant, props.getRoleCode(), props.getRoleName()); + } + + private RoleId ensureRoleId(TenantId tenant, String code, String name) { + return roleRepository.findByTenantIdAndCode(tenant.value(), code) .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()); + var role = roleAdminService.create(tenant, code, name); + log.info("[devslab-kit bootstrap] created 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("[devslab-kit bootstrap] created permission {}", perm.code()); - return permissionRepository.findByCode(perm.code()) - .map(e -> PermissionId.of(e.getId())) - .orElseThrow(); - }); - rolePermissionService.grant(adminRole, permId); // idempotent + rolePermissionService.grant(adminRole, ensurePermissionId(perm.code(), perm.description())); // idempotent + } + } + + private PermissionId ensurePermissionId(String code, String description) { + return permissionRepository.findByCode(code) + .map(entity -> PermissionId.of(entity.getId())) + .orElseGet(() -> { + permissionAdminService.create(code, description); + log.info("[devslab-kit bootstrap] created permission {}", code); + return permissionRepository.findByCode(code) + .map(e -> PermissionId.of(e.getId())) + .orElseThrow(); + }); + } + + /** + * Idempotently apply the declarative RBAC seed (permissions + roles + grants) + * from {@code devslab.kit.bootstrap.seed}. Additive only: missing entities are + * created and listed grants added; nothing is revoked or deleted. A permission + * referenced by a seeded role is auto-created if absent. Roles live in the + * bootstrap tenant; permissions are global. + */ + private void seedDeclaredRbac(TenantId tenant) { + DevslabKitProperties.Bootstrap.Seed seed = props.getSeed(); + + for (String code : seed.getPermissions()) { + if (code != null && !code.isBlank()) { + ensurePermissionId(code.trim(), ""); + } + } + + for (Map.Entry> entry : seed.getRoles().entrySet()) { + String roleCode = entry.getKey(); + if (roleCode == null || roleCode.isBlank()) { + continue; + } + RoleId role = ensureRoleId(tenant, roleCode.trim(), roleCode.trim()); + List grants = entry.getValue() == null ? List.of() : entry.getValue(); + for (String permCode : grants) { + if (permCode != null && !permCode.isBlank()) { + rolePermissionService.grant(role, ensurePermissionId(permCode.trim(), "")); // idempotent + } + } + } + + if (!seed.getPermissions().isEmpty() || !seed.getRoles().isEmpty()) { + log.info("[devslab-kit bootstrap] seeded RBAC: {} permission(s), {} role(s)", + seed.getPermissions().size(), seed.getRoles().size()); } } 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 ccf1d57..dbe0737 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 @@ -1,5 +1,9 @@ package kr.devslab.kit.autoconfigure; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import kr.devslab.kit.tenant.TenantMode; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -288,6 +292,9 @@ public static class Bootstrap { */ private boolean failOnDefaultPasswordInProd = true; + /** Optional starter RBAC (permissions + roles) seeded on boot — see {@link Seed}. */ + private final Seed seed = new Seed(); + public boolean isEnabled() { return enabled; } @@ -359,6 +366,61 @@ public boolean isFailOnDefaultPasswordInProd() { public void setFailOnDefaultPasswordInProd(boolean failOnDefaultPasswordInProd) { this.failOnDefaultPasswordInProd = failOnDefaultPasswordInProd; } + + public Seed getSeed() { + return seed; + } + + /** + * Optional starter RBAC, seeded idempotently on boot alongside the admin: + * declare your domain permissions and the roles that group them, and the kit + * creates whatever is missing and grants it. Re-applied every boot and + * additive only — it never revokes or deletes, so adding a + * permission/role here and redeploying picks it up, while destructive + * reconciliation stays the job of dev-only config-sync mirror. + * + *
+         * devslab:
+         *   kit:
+         *     bootstrap:
+         *       enabled: true
+         *       seed:
+         *         permissions: [tasks.read, tasks.write, tasks.update, tasks.delete]
+         *         roles:
+         *           viewer: [tasks.read]
+         *           editor: [tasks.read, tasks.write, tasks.update]
+         *           owner:  [tasks.read, tasks.write, tasks.update, tasks.delete]
+         * 
+ * + *

Permissions are global; roles are created in {@link #getTenantId()}. A + * permission code referenced by a role but absent from {@code permissions} is + * created automatically, so the explicit {@code permissions} list is only + * needed for codes you want to exist without granting them anywhere yet. + */ + public static class Seed { + + /** Permission codes to ensure exist (created if absent). */ + private List permissions = new ArrayList<>(); + + /** Role code → the permission codes that role should grant. */ + private Map> roles = new LinkedHashMap<>(); + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + public Map> getRoles() { + return roles; + } + + public void setRoles(Map> roles) { + this.roles = roles; + } + } } /** diff --git a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/BootstrapSeedTests.java b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/BootstrapSeedTests.java new file mode 100644 index 0000000..5c05e5f --- /dev/null +++ b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/BootstrapSeedTests.java @@ -0,0 +1,93 @@ +package kr.devslab.kit.sample; + +import static org.assertj.core.api.Assertions.assertThat; + +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.RolePermissionService; +import kr.devslab.kit.core.id.PermissionId; +import kr.devslab.kit.core.id.RoleId; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +/** + * Proves {@code devslab.kit.bootstrap.seed} provisions starter RBAC on boot: the + * declared permissions, roles and grants are created, a permission referenced only + * by a role is auto-created, and grants are precise (a role gets exactly what it + * lists — no over-granting). Seeding is idempotent by construction (each step is a + * find-by-code-then-create / idempotent grant), so a re-boot against this same data + * is a no-op. + */ +@Import(TestcontainersConfiguration.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "devslab.kit.bootstrap.enabled=true", + "devslab.kit.bootstrap.seed.permissions[0]=tasks.read", + "devslab.kit.bootstrap.seed.permissions[1]=tasks.write", + "devslab.kit.bootstrap.seed.permissions[2]=tasks.update", + "devslab.kit.bootstrap.seed.permissions[3]=tasks.delete", + "devslab.kit.bootstrap.seed.roles.viewer[0]=tasks.read", + "devslab.kit.bootstrap.seed.roles.editor[0]=tasks.read", + "devslab.kit.bootstrap.seed.roles.editor[1]=tasks.write", + "devslab.kit.bootstrap.seed.roles.editor[2]=tasks.update", + "devslab.kit.bootstrap.seed.roles.owner[0]=tasks.read", + "devslab.kit.bootstrap.seed.roles.owner[1]=tasks.write", + "devslab.kit.bootstrap.seed.roles.owner[2]=tasks.update", + "devslab.kit.bootstrap.seed.roles.owner[3]=tasks.delete", + // referenced only by a role, absent from the permissions list — must be auto-created: + "devslab.kit.bootstrap.seed.roles.owner[4]=tasks.export" + }) +class BootstrapSeedTests { + + private static final String TENANT = "default"; + + @Autowired + private JpaPlatformPermissionRepository permissions; + + @Autowired + private JpaPlatformRoleRepository roles; + + @Autowired + private RolePermissionService rolePermissions; + + @Test + void seedsDeclaredPermissions() { + for (String code : List.of("tasks.read", "tasks.write", "tasks.update", "tasks.delete")) { + assertThat(permissions.findByCode(code)).as("permission %s", code).isPresent(); + } + } + + @Test + void autoCreatesAPermissionReferencedOnlyByARole() { + assertThat(permissions.findByCode("tasks.export")).isPresent(); + } + + @Test + void seedsRolesInTheBootstrapTenant() { + assertThat(roles.findByTenantIdAndCode(TENANT, "viewer")).isPresent(); + assertThat(roles.findByTenantIdAndCode(TENANT, "editor")).isPresent(); + assertThat(roles.findByTenantIdAndCode(TENANT, "owner")).isPresent(); + } + + @Test + void grantsAreExactPerRole() { + var editor = roles.findByTenantIdAndCode(TENANT, "editor").orElseThrow(); + List editorPerms = rolePermissions.findPermissionIdsForRole(RoleId.of(editor.getId())); + + PermissionId read = permissionId("tasks.read"); + PermissionId write = permissionId("tasks.write"); + PermissionId update = permissionId("tasks.update"); + PermissionId delete = permissionId("tasks.delete"); + + assertThat(editorPerms).contains(read, write, update); + assertThat(editorPerms).doesNotContain(delete); // editor never listed delete + } + + private PermissionId permissionId(String code) { + return PermissionId.of(permissions.findByCode(code).orElseThrow().getId()); + } +} diff --git a/docs/getting-started/installation.ko.md b/docs/getting-started/installation.ko.md index 87f04e8..e87601c 100644 --- a/docs/getting-started/installation.ko.md +++ b/docs/getting-started/installation.ko.md @@ -17,13 +17,13 @@ === "Gradle (Kotlin DSL)" ```kotlin - implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") + implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") ``` === "Gradle (Groovy)" ```groovy - implementation 'kr.devslab:devslab-kit-spring-boot-starter:0.4.2' + implementation 'kr.devslab:devslab-kit-spring-boot-starter:0.5.0' ``` === "Maven" @@ -32,7 +32,7 @@ kr.devslab devslab-kit-spring-boot-starter - 0.4.2 + 0.5.0 ``` @@ -43,10 +43,10 @@ 물러납니다(`@ConditionalOnMissingBean`). ```kotlin -implementation("kr.devslab:devslab-kit-access-core:0.4.2") // RBAC + 그룹 + ABAC -implementation("kr.devslab:devslab-kit-cache-core:0.4.2") // 플러그형 캐시 +implementation("kr.devslab:devslab-kit-access-core:0.5.0") // RBAC + 그룹 + ABAC +implementation("kr.devslab:devslab-kit-cache-core:0.5.0") // 플러그형 캐시 // …또는 계약만: -implementation("kr.devslab:devslab-kit-access-api:0.4.2") +implementation("kr.devslab:devslab-kit-access-api:0.5.0") ``` 동작하는 앱을 부팅하려면 [빠른 시작](quick-start.md)을 참고하세요. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 718cedd..be45068 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -17,13 +17,13 @@ whole platform; depend on individual modules only if you want à la carte. === "Gradle (Kotlin DSL)" ```kotlin - implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") + implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") ``` === "Gradle (Groovy)" ```groovy - implementation 'kr.devslab:devslab-kit-spring-boot-starter:0.4.2' + implementation 'kr.devslab:devslab-kit-spring-boot-starter:0.5.0' ``` === "Maven" @@ -32,7 +32,7 @@ whole platform; depend on individual modules only if you want à la carte. kr.devslab devslab-kit-spring-boot-starter - 0.4.2 + 0.5.0 ``` @@ -44,10 +44,10 @@ your own — the auto-configuration backs off (`@ConditionalOnMissingBean`) when do. ```kotlin -implementation("kr.devslab:devslab-kit-access-core:0.4.2") // RBAC + groups + ABAC -implementation("kr.devslab:devslab-kit-cache-core:0.4.2") // pluggable cache +implementation("kr.devslab:devslab-kit-access-core:0.5.0") // RBAC + groups + ABAC +implementation("kr.devslab:devslab-kit-cache-core:0.5.0") // pluggable cache // …or just the contract: -implementation("kr.devslab:devslab-kit-access-api:0.4.2") +implementation("kr.devslab:devslab-kit-access-api:0.5.0") ``` See [Quick Start](quick-start.md) to boot a working app. diff --git a/docs/getting-started/tutorial.ko.md b/docs/getting-started/tutorial.ko.md index f6c60a5..ab7bfe6 100644 --- a/docs/getting-started/tutorial.ko.md +++ b/docs/getting-started/tutorial.ko.md @@ -57,7 +57,7 @@ repositories { mavenCentral() } dependencies { // 플랫폼: 인증, RBAC + 그룹 + ABAC, 멀티테넌시, 동적 메뉴, 감사 로깅, // 관리자 REST API — 전부 자동 구성. - implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") + implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") // devslab-kit은 어떤 Spring 스타터를 쓸지 강요하지 않습니다. // 이 튜토리얼에선 web + security + JPA + Flyway + PostgreSQL. diff --git a/docs/getting-started/tutorial.md b/docs/getting-started/tutorial.md index 737ff2f..cd718b8 100644 --- a/docs/getting-started/tutorial.md +++ b/docs/getting-started/tutorial.md @@ -59,7 +59,7 @@ repositories { mavenCentral() } dependencies { // The platform: authentication, RBAC + groups + ABAC, multi-tenancy, // dynamic menus, audit logging, and an admin REST API — all auto-configured. - implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") + implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") // devslab-kit is unopinionated about which Spring starters you bring. // For this tutorial we want web + security + JPA + Flyway + PostgreSQL. diff --git a/docs/guides/access.ko.md b/docs/guides/access.ko.md index 991be01..ec1c46e 100644 --- a/docs/guides/access.ko.md +++ b/docs/guides/access.ko.md @@ -31,6 +31,10 @@ [부트스트랩](bootstrap.md)이 이미 전체 `admin.*`을 가진 `PLATFORM_ADMIN`을 심어 둡니다 — 여기서는 직접 추가하는 법입니다.) +!!! tip "클릭 대신 설정으로 시드" + 환경마다 스타터 역할을 손으로 만들지 않으려면 `devslab.kit.bootstrap.seed`에 선언하세요 — + kit이 부팅 시 멱등하게 생성합니다. [최초 관리자 부트스트랩 → 시드](bootstrap.md#seed) 참고. + === "관리자 콘솔" [관리자 콘솔](admin-console.md)에서: diff --git a/docs/guides/access.md b/docs/guides/access.md index 16010ee..47a2223 100644 --- a/docs/guides/access.md +++ b/docs/guides/access.md @@ -31,6 +31,11 @@ You define permissions, group them into roles, and assign roles to users. (The first-admin [bootstrap](bootstrap.md) already seeds `PLATFORM_ADMIN` with the full `admin.*` set — this is how you add your own.) +!!! tip "Seed them from config instead of clicking" + To avoid hand-creating starter roles in every environment, declare them under + `devslab.kit.bootstrap.seed` and the kit creates them idempotently on boot — + see [First-admin Bootstrap → Seed](bootstrap.md#seed). + === "Admin console" In the [admin console](admin-console.md): diff --git a/docs/guides/bootstrap.ko.md b/docs/guides/bootstrap.ko.md index e8b7164..8b33a2a 100644 --- a/docs/guides/bootstrap.ko.md +++ b/docs/guides/bootstrap.ko.md @@ -33,6 +33,38 @@ devslab: 모든 키는 [설정 레퍼런스](../reference/configuration.md#bootstrap) 참고. +## 스타터 역할·권한 시드 { #seed } + +빈 데이터베이스에는 관리자만 있고 애플리케이션 역할은 없습니다. consumer가 콘솔에서 일일이 +만들게 하는 대신 **설정으로 시드**하세요 — kit이 부팅 시 권한·역할을 멱등하게 생성하고 각 역할에 +권한을 부여합니다: + +```yaml +# src/main/resources/application.yml +devslab: + kit: + bootstrap: + enabled: true + seed: + permissions: [tasks.read, tasks.write, tasks.update, tasks.delete] + roles: + viewer: [tasks.read] + editor: [tasks.read, tasks.write, tasks.update] + owner: [tasks.read, tasks.write, tasks.update, tasks.delete] +``` + +`tasks.*`는 placeholder입니다 — **당신** 도메인의 권한 코드를 나열하세요. 참고: + +- **멱등 + 추가형.** 매 부팅 시 없는 것을 만들고 나열된 grant를 추가하되, 회수·삭제는 안 합니다. + 여기에 권한/역할을 추가하고 재배포하면 반영됩니다. (파괴적 동기화는 dev 전용 + [설정 동기화](config-sync.md) `mirror` 담당.) +- 역할이 나열했지만 `permissions` 블록엔 없는 권한은 **자동 생성**됩니다 — 그래서 + `permissions`는 아직 아무 데도 부여하지 않을 코드를 위한 것입니다. +- 권한은 전역, 역할은 `tenant-id`에 생성됩니다. + +[관리자 콘솔](admin-console.md)에서 계속 만들고 관리할 수 있습니다 — 시드는 빈 상태 대신 합리적인 +출발점을 줄 뿐입니다. + ## 비밀번호 - 알려진 값이 필요하면 **명시 설정**(예: 로컬 개발 `admin`/`admin`). diff --git a/docs/guides/bootstrap.md b/docs/guides/bootstrap.md index dc9ff63..157b028 100644 --- a/docs/guides/bootstrap.md +++ b/docs/guides/bootstrap.md @@ -36,6 +36,39 @@ devslab: See the [Configuration reference](../reference/configuration.md#bootstrap) for every key. +## Seed starter roles & permissions { #seed } + +A fresh database has the admin but no application roles. Rather than make every +consumer hand-create them in the console, **seed them from config** — the kit creates +the permissions and roles idempotently on boot and grants each role its permissions: + +```yaml +# src/main/resources/application.yml +devslab: + kit: + bootstrap: + enabled: true + seed: + permissions: [tasks.read, tasks.write, tasks.update, tasks.delete] + roles: + viewer: [tasks.read] + editor: [tasks.read, tasks.write, tasks.update] + owner: [tasks.read, tasks.write, tasks.update, tasks.delete] +``` + +`tasks.*` is a placeholder — list **your** domain's permission codes. Notes: + +- **Idempotent + additive.** Every boot creates what's missing and adds the listed + grants; it never revokes or deletes, so adding a permission/role here and + redeploying picks it up. (Destructive reconciliation is the dev-only job of + [config sync](config-sync.md) `mirror`.) +- A permission a role lists but the `permissions` block omits is **auto-created** — + so `permissions` is only for codes you want to exist without granting them yet. +- Permissions are global; roles are created in `tenant-id`. + +You can still create and manage everything in the [admin console](admin-console.md) — +the seed just gives you a sensible starting point instead of a blank slate. + ## The password - **Set explicitly** for a known value (e.g. local dev `admin`/`admin`). diff --git a/docs/reference/configuration.ko.md b/docs/reference/configuration.ko.md index 8075a21..616d6ea 100644 --- a/docs/reference/configuration.ko.md +++ b/docs/reference/configuration.ko.md @@ -29,8 +29,10 @@ - `fixed` — 항상 `default-tenant-id` 반환. `single` 모드의 자연스러운 선택. - `header` — 요청 헤더(`header` 속성, 기본 `X-Tenant-Id`)에서 테넌트 id를 읽음. -- `jwt` — 인증된 JWT의 클레임에서 테넌트를 읽음. - `subdomain` — 요청 호스트의 서브도메인에서 유도(`acme.example.com` → `acme`). +- `jwt` — _예약, 아직 미출시_: 선택하면 부팅 시 즉시 실패합니다 + (`devslab-kit-oauth2-resource-server-starter` 대기). 로그인 JWT는 이미 `tenant` 클레임을 + 실으니, 그때까지는 커스텀 `TenantResolver` 빈으로 읽으세요. [멀티테넌시 가이드](../guides/tenancy.md) 참고. @@ -95,6 +97,25 @@ | `admin-password` | string | — | 관리자 비밀번호. **비우면** 강력한 랜덤 비밀번호를 생성해 시작 시 **한 번** 로깅. | | `admin-email` | string | — | 시드 관리자의 선택적 이메일. | | `must-change-password` | boolean | `true` | 첫 로그인 시 새 비밀번호 설정 강제. | +| `seed.permissions` | list | `[]` | 부팅 시 생성할 권한 코드(멱등). | +| `seed.roles` | map | `{}` | 역할 코드 → 권한 코드; 역할을 생성하고 나열된 권한을 부여. | + +시드는 **멱등·추가형**입니다 — 매 부팅 시 없는 권한/역할을 생성하고 나열된 grant를 추가하되, +회수·삭제는 하지 않습니다(역할이 참조하는 권한은 자동 생성). 스타터 역할을 함께 배포해 consumer가 +직접 만들지 않게 하세요: + +```yaml +devslab: + kit: + bootstrap: + enabled: true + seed: + permissions: [tasks.read, tasks.write, tasks.update, tasks.delete] + roles: + viewer: [tasks.read] + editor: [tasks.read, tasks.write, tasks.update] + owner: [tasks.read, tasks.write, tasks.update, tasks.delete] +``` !!! warning "운영 환경" `identity.jwt.secret`은 항상 강력하게 설정하세요. 부트스트랩은 강력한 @@ -121,7 +142,7 @@ === "Gradle (Kotlin DSL)" ```kotlin - implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") { + implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") { exclude(group = "org.springdoc") } ``` @@ -132,7 +153,7 @@ kr.devslab devslab-kit-spring-boot-starter - 0.4.2 + 0.5.0 org.springdoc diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 94a92d6..074dc0a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -31,9 +31,11 @@ are viewable at runtime via `GET /admin/api/v1/settings` (secrets masked). - `fixed` — always returns `default-tenant-id`. The natural pick for `single` mode. - `header` — reads the tenant id from a request header (the `header` property, default `X-Tenant-Id`). -- `jwt` — reads the tenant from a claim on the authenticated JWT. - `subdomain` — derives it from the request host's subdomain (`acme.example.com` → `acme`). +- `jwt` — _reserved, not yet shipped_: selecting it fails fast at startup (it awaits + the `devslab-kit-oauth2-resource-server-starter`). The login JWT already carries a + `tenant` claim, so resolve it from a custom `TenantResolver` bean meanwhile. See the [Multi-tenancy guide](../guides/tenancy.md). @@ -100,6 +102,26 @@ opt in explicitly. | `admin-password` | string | — | The admin's password. **Leave blank** to have a strong random one generated and logged **once** at startup. | | `admin-email` | string | — | Optional email for the seeded admin. | | `must-change-password` | boolean | `true` | Force the admin to set a new password on first login. | +| `seed.permissions` | list | `[]` | Permission codes to create on boot (idempotent). | +| `seed.roles` | map | `{}` | Role code → permission codes; the role is created and the listed permissions granted. | + +The seed is **idempotent and additive** — on every boot it creates missing +permissions/roles and adds the listed grants, but never revokes or deletes (and a +permission a role references is auto-created). Ship starter roles so consumers don't +hand-create them: + +```yaml +devslab: + kit: + bootstrap: + enabled: true + seed: + permissions: [tasks.read, tasks.write, tasks.update, tasks.delete] + roles: + viewer: [tasks.read] + editor: [tasks.read, tasks.write, tasks.update] + owner: [tasks.read, tasks.write, tasks.update, tasks.delete] +``` !!! warning "Production" Always set a strong `identity.jwt.secret`. For the bootstrap, either set a @@ -128,7 +150,7 @@ Two ways to turn it off: === "Gradle (Kotlin DSL)" ```kotlin - implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2") { + implementation("kr.devslab:devslab-kit-spring-boot-starter:0.5.0") { exclude(group = "org.springdoc") } ``` @@ -139,7 +161,7 @@ Two ways to turn it off: kr.devslab devslab-kit-spring-boot-starter - 0.4.2 + 0.5.0 org.springdoc diff --git a/docs/roadmap.ko.md b/docs/roadmap.ko.md index 9c1c9ad..cc80798 100644 --- a/docs/roadmap.ko.md +++ b/docs/roadmap.ko.md @@ -5,7 +5,7 @@ ## 현재 상태 -`devslab-kit`은 Maven Central에 배포돼 있고 — 최신 **0.4.2** — 이 문서 사이트와 동반 +`devslab-kit`은 Maven Central에 배포돼 있고 — 최신 **0.5.0** — 이 문서 사이트와 동반 [관리자 콘솔](https://github.com/devslab-kr/devslab-kit-admin-ui)을 함께 제공합니다. 플랫폼은 그 범위 안에서 기능이 완성돼 있습니다: diff --git a/docs/roadmap.md b/docs/roadmap.md index a800539..91df5c8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,7 +6,7 @@ weigh in. ## Where it is today -`devslab-kit` is published on Maven Central — latest **0.4.2** — alongside this +`devslab-kit` is published on Maven Central — latest **0.5.0** — alongside this documentation site and the companion [admin console](https://github.com/devslab-kr/devslab-kit-admin-ui). The platform is feature-complete for its scope: From 578430bf243176d85bb616505e376dece3ee18f8 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Wed, 3 Jun 2026 23:38:20 +0900 Subject: [PATCH 2/2] feat: JWT tenant resolver (reads the kit token's tenant claim) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devslab.kit.tenant.resolver: jwt` now resolves the active tenant instead of failing fast at startup. It reads the kit-issued bearer token (which already carries a `tenant` claim) and falls back to `default-tenant-id` when there's no token — e.g. the login request itself. - JwtTenantResolver (tenant-core): reads `Authorization: Bearer`, parses via AuthTokenService, uses CurrentUser.tenantId(); same request-access pattern as HeaderTenantResolver. tenant-core gains compileOnly(identity-api) only — a consumer on another resolver never pulls identity. - TenantAutoConfiguration: `case JWT` wires it (AuthTokenService via ObjectProvider; clear error if the identity module is absent). - JwtTenantResolverTests (sample-app): wiring + resolves "acme" from a bearer token + falls back to "default" without one. - docs: tenancy + configuration `jwt` rows go from "reserved" to working, scoped to the kit's own HS256 token (external OAuth2/OIDC stays out of scope); CHANGELOG 0.5.0. Scope: this reads the kit's own token. Validating external OAuth2/OIDC tokens (JWKS, issuer, configurable claim) remains a separate future concern. Verification: ./gradlew build BUILD SUCCESSFUL; JwtTenantResolverTests 3/0/0, BootstrapSeedTests 4/0/0; mkdocs build --strict clean. --- CHANGELOG.ko.md | 4 + CHANGELOG.md | 5 ++ .../TenantAutoConfiguration.java | 22 +++-- .../kit/sample/JwtTenantResolverTests.java | 81 +++++++++++++++++++ devslab-kit-tenant-core/build.gradle.kts | 5 ++ .../kit/tenant/core/JwtTenantResolver.java | 66 +++++++++++++++ docs/guides/tenancy.ko.md | 14 ++-- docs/guides/tenancy.md | 15 ++-- docs/reference/configuration.ko.md | 5 +- docs/reference/configuration.md | 6 +- 10 files changed, 198 insertions(+), 25 deletions(-) create mode 100644 devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/JwtTenantResolverTests.java create mode 100644 devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/JwtTenantResolver.java diff --git a/CHANGELOG.ko.md b/CHANGELOG.ko.md index e01ec2f..c758513 100644 --- a/CHANGELOG.ko.md +++ b/CHANGELOG.ko.md @@ -32,6 +32,10 @@ English: [CHANGELOG.md](CHANGELOG.md) 추가형(additive)입니다 — 매 부팅 시 없는 것을 만들고 나열된 grant를 추가하되, 회수·삭제는 하지 않습니다. 역할이 참조하는 권한은 자동 생성됩니다. 권한은 전역, 역할은 `bootstrap.tenant-id`에 생성됩니다. [부트스트랩 가이드](guides/bootstrap.md#seed) 참고. +- **JWT 테넌트 리졸버.** `devslab.kit.tenant.resolver: jwt`가 이제 부팅 시 실패하는 대신, kit이 + 발급한 bearer 토큰의 `tenant` 클레임에서 활성 테넌트를 resolve합니다(토큰이 없으면 + `default-tenant-id`로 폴백). kit 자체 HS256 토큰을 읽으며, 외부 OAuth2 / OIDC 토큰 검증은 별도 + 과제입니다. [멀티테넌시 가이드](guides/tenancy.md) 참고. ## [0.4.2] — 2026-06-03 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c998f..80ce6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ The library major aligns with the Spring Boot major: `4.x.y` targets Spring Boot never revokes or deletes; a permission referenced by a role is auto-created. Permissions are global; roles are created in `bootstrap.tenant-id`. See the [bootstrap guide](guides/bootstrap.md#seed). +- **JWT tenant resolver.** `devslab.kit.tenant.resolver: jwt` now resolves the active + tenant from the kit-issued bearer token's `tenant` claim (falling back to + `default-tenant-id` when there's no token) instead of failing at startup. It reads + the kit's own HS256 token; validating external OAuth2 / OIDC tokens remains a + separate concern. See the [tenancy guide](guides/tenancy.md). ## [0.4.2] — 2026-06-03 diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java index bf04edd..bf84be0 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java @@ -2,15 +2,18 @@ import jakarta.persistence.EntityManager; import java.time.Clock; +import kr.devslab.kit.identity.AuthTokenService; import kr.devslab.kit.tenant.TenantContextHolder; import kr.devslab.kit.tenant.TenantResolver; import kr.devslab.kit.tenant.TenantService; import kr.devslab.kit.tenant.core.DefaultTenantContextHolder; import kr.devslab.kit.tenant.core.FixedTenantResolver; import kr.devslab.kit.tenant.core.HeaderTenantResolver; +import kr.devslab.kit.tenant.core.JwtTenantResolver; import kr.devslab.kit.tenant.core.SubdomainTenantResolver; import kr.devslab.kit.tenant.core.repository.JpaPlatformTenantRepository; import kr.devslab.kit.tenant.core.service.DefaultTenantService; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -39,16 +42,25 @@ public TenantContextHolder tenantContextHolder() { @Bean @ConditionalOnMissingBean - public TenantResolver tenantResolver(DevslabKitProperties properties) { + public TenantResolver tenantResolver( + DevslabKitProperties properties, + ObjectProvider authTokenService + ) { DevslabKitProperties.Tenant tenant = properties.getTenant(); return switch (tenant.getResolver()) { case FIXED -> new FixedTenantResolver(tenant.getDefaultTenantId()); case HEADER -> new HeaderTenantResolver(tenant.getHeaderName(), tenant.getDefaultTenantId()); case SUBDOMAIN -> new SubdomainTenantResolver(tenant.getSubdomainIndex(), tenant.getDefaultTenantId()); - case JWT -> throw new IllegalStateException( - "JWT TenantResolver requires the devslab-kit-oauth2-resource-server-starter (not yet shipped). " - + "Provide a custom TenantResolver bean for now." - ); + case JWT -> { + AuthTokenService tokens = authTokenService.getIfAvailable(); + if (tokens == null) { + throw new IllegalStateException( + "resolver: jwt requires an AuthTokenService bean (the identity module); it reads " + + "the tenant claim from the kit-issued bearer token." + ); + } + yield new JwtTenantResolver(tokens, tenant.getDefaultTenantId()); + } case CUSTOM -> throw new IllegalStateException( "Resolver mode CUSTOM requires the consumer app to define its own TenantResolver bean. " + "Add one and the @ConditionalOnMissingBean default will step aside." diff --git a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/JwtTenantResolverTests.java b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/JwtTenantResolverTests.java new file mode 100644 index 0000000..9fae717 --- /dev/null +++ b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/JwtTenantResolverTests.java @@ -0,0 +1,81 @@ +package kr.devslab.kit.sample; + +import static org.assertj.core.api.Assertions.assertThat; + +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.AuthTokenService; +import kr.devslab.kit.identity.CurrentUser; +import kr.devslab.kit.identity.UserStatus; +import kr.devslab.kit.tenant.TenantResolver; +import kr.devslab.kit.tenant.core.JwtTenantResolver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * Proves {@code resolver: jwt} actually works: the kit wires {@link JwtTenantResolver}, + * it derives the active tenant from the kit-issued bearer token's tenant claim, and it + * falls back to the configured default tenant when there is no token. + */ +@Import(TestcontainersConfiguration.class) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "devslab.kit.tenant.mode=multi", + "devslab.kit.tenant.resolver=jwt", + "devslab.kit.tenant.default-tenant-id=default" + }) +class JwtTenantResolverTests { + + @Autowired + private TenantResolver resolver; + + @Autowired + private AuthTokenService tokens; + + @AfterEach + void clearRequest() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void wiresTheJwtResolver() { + assertThat(resolver).isInstanceOf(JwtTenantResolver.class); + } + + @Test + void resolvesTenantFromBearerToken() { + CurrentUser user = new CurrentUser( + UserId.of(UUID.randomUUID()), + PublicId.of("pub-acme"), + TenantId.of("acme"), + "alice", + UserStatus.ACTIVE, + Set.of(), + false); + String token = tokens.issue(user).value(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + assertThat(resolver.resolve().tenantId().value()).isEqualTo("acme"); + } + + @Test + void fallsBackToDefaultWithoutAToken() { + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(new MockHttpServletRequest())); + + assertThat(resolver.resolve().tenantId().value()).isEqualTo("default"); + } +} diff --git a/devslab-kit-tenant-core/build.gradle.kts b/devslab-kit-tenant-core/build.gradle.kts index 9a39c15..c96d435 100644 --- a/devslab-kit-tenant-core/build.gradle.kts +++ b/devslab-kit-tenant-core/build.gradle.kts @@ -8,6 +8,11 @@ dependencies { compileOnly("org.springframework:spring-web") compileOnly("jakarta.servlet:jakarta.servlet-api") + // Only the `jwt` resolver needs identity (to read the bearer token's tenant + // claim); compileOnly so tenant-core never forces identity on a consumer that + // uses a different resolver. + compileOnly(project(":devslab-kit-identity-api")) + compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") } diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/JwtTenantResolver.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/JwtTenantResolver.java new file mode 100644 index 0000000..9a1d9d0 --- /dev/null +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/JwtTenantResolver.java @@ -0,0 +1,66 @@ +package kr.devslab.kit.tenant.core; + +import jakarta.servlet.http.HttpServletRequest; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.identity.AuthTokenService; +import kr.devslab.kit.tenant.TenantContext; +import kr.devslab.kit.tenant.TenantResolver; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * Resolves the active tenant from the kit-issued bearer JWT: reads the + * {@code Authorization: Bearer } header, parses it with + * {@link AuthTokenService}, and uses the token's tenant claim. Falls back to the + * configured default tenant when there is no valid token — e.g. on the login + * request itself, or an unauthenticated probe. + * + *

This reads the kit's own HS256 admin token (which already carries a + * {@code tenant} claim). Validating external OAuth2 / OIDC tokens (JWKS, + * issuer checks, a configurable claim name) is a separate, larger concern and is + * intentionally out of scope here. + */ +public class JwtTenantResolver implements TenantResolver { + + private static final String AUTHORIZATION = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final AuthTokenService authTokenService; + private final String defaultTenantId; + + public JwtTenantResolver(AuthTokenService authTokenService, String defaultTenantId) { + this.authTokenService = authTokenService; + this.defaultTenantId = defaultTenantId; + } + + @Override + public TenantContext resolve() { + HttpServletRequest request = currentRequest(); + if (request != null) { + String header = request.getHeader(AUTHORIZATION); + if (header != null && header.startsWith(BEARER_PREFIX)) { + String token = header.substring(BEARER_PREFIX.length()).trim(); + TenantContext fromToken = authTokenService.parse(token) + .map(user -> new TenantContext(user.tenantId())) + .orElse(null); + if (fromToken != null) { + return fromToken; + } + } + } + if (defaultTenantId != null && !defaultTenantId.isBlank()) { + return new TenantContext(TenantId.of(defaultTenantId)); + } + throw new IllegalStateException( + "No valid bearer token to resolve a tenant from, and no default tenant configured" + ); + } + + private HttpServletRequest currentRequest() { + var attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes servletAttrs) { + return servletAttrs.getRequest(); + } + return null; + } +} diff --git a/docs/guides/tenancy.ko.md b/docs/guides/tenancy.ko.md index 2fedbba..17e1e30 100644 --- a/docs/guides/tenancy.ko.md +++ b/docs/guides/tenancy.ko.md @@ -16,7 +16,7 @@ devslab: kit: tenant: mode: single # single | multi - resolver: fixed # fixed | header | subdomain (jwt: 예약 — 아래 참고) + resolver: fixed # fixed | header | jwt | subdomain default-tenant-id: default ``` @@ -36,14 +36,14 @@ devslab: | --- | --- | --- | | `fixed` | 항상 `default-tenant-id` | (싱글 테넌트 기본) | | `header` | 요청 헤더(기본 `X-Tenant-Id`) | `X-Tenant-Id: acme` | +| `jwt` | kit이 발급한 bearer 토큰의 `tenant` 클레임 | 로그인 사용자의 테넌트 | | `subdomain` | 요청 호스트의 서브도메인 | `acme.app.com` → `acme` | -| `jwt` | _예약 — 아직 미출시_ | (노트 참고) | -!!! warning "`jwt`는 예약됨 (아직 미출시)" - `resolver: jwt`를 선택하면 부팅 시 즉시 실패합니다 — 예정된 - `devslab-kit-oauth2-resource-server-starter`를 기다리는 중입니다. 로그인 JWT는 이미 `tenant` - 클레임을 싣고 있으니, 그때까지는 **커스텀 `TenantResolver` 빈**으로 읽으세요 - (아래 [커스텀 리졸버](#custom-resolver)). +!!! note "`jwt` 리졸버가 읽는 것" + **kit 자체** bearer 토큰(`/auth/login`이 발급하며 `tenant` 클레임을 실음)을 파싱하고, + 토큰이 없으면 `default-tenant-id`로 폴백합니다(예: 로그인 요청 자체). *외부* OAuth2 / OIDC + 토큰 검증(JWKS, issuer 확인, 설정 가능한 클레임명)은 여기서 다루지 않는 별도 과제입니다 — + 그건 아래 [커스텀 리졸버](#custom-resolver)로 처리하세요. ```yaml devslab: diff --git a/docs/guides/tenancy.md b/docs/guides/tenancy.md index 8864c40..705a69d 100644 --- a/docs/guides/tenancy.md +++ b/docs/guides/tenancy.md @@ -17,7 +17,7 @@ devslab: kit: tenant: mode: single # single | multi - resolver: fixed # fixed | header | subdomain (jwt: reserved — see below) + resolver: fixed # fixed | header | jwt | subdomain default-tenant-id: default ``` @@ -37,14 +37,15 @@ In `multi` mode the **resolver** decides whose request this is: | --- | --- | --- | | `fixed` | always `default-tenant-id` | (the single-tenant default) | | `header` | a request header (default `X-Tenant-Id`) | `X-Tenant-Id: acme` | +| `jwt` | the `tenant` claim on the kit-issued bearer token | the signed-in user's tenant | | `subdomain` | the request host's subdomain | `acme.app.com` → `acme` | -| `jwt` | _reserved — not yet shipped_ | (see note) | -!!! warning "`jwt` is reserved (not yet shipped)" - Selecting `resolver: jwt` fails fast at startup — it awaits the planned - `devslab-kit-oauth2-resource-server-starter`. The login JWT already carries a - `tenant` claim, so until then resolve it from a **custom `TenantResolver` bean** - ([Custom resolver](#custom-resolver) below). +!!! note "What the `jwt` resolver reads" + It parses the **kit's own** bearer token (the one `/auth/login` issues, which + carries a `tenant` claim) and falls back to `default-tenant-id` when there's no + token — e.g. the login request itself. Validating *external* OAuth2 / OIDC tokens + (JWKS, issuer checks, a configurable claim name) is a separate, larger concern not + covered here; for that, supply a [custom resolver](#custom-resolver) below. ```yaml devslab: diff --git a/docs/reference/configuration.ko.md b/docs/reference/configuration.ko.md index 616d6ea..0692863 100644 --- a/docs/reference/configuration.ko.md +++ b/docs/reference/configuration.ko.md @@ -30,9 +30,8 @@ - `fixed` — 항상 `default-tenant-id` 반환. `single` 모드의 자연스러운 선택. - `header` — 요청 헤더(`header` 속성, 기본 `X-Tenant-Id`)에서 테넌트 id를 읽음. - `subdomain` — 요청 호스트의 서브도메인에서 유도(`acme.example.com` → `acme`). -- `jwt` — _예약, 아직 미출시_: 선택하면 부팅 시 즉시 실패합니다 - (`devslab-kit-oauth2-resource-server-starter` 대기). 로그인 JWT는 이미 `tenant` 클레임을 - 실으니, 그때까지는 커스텀 `TenantResolver` 빈으로 읽으세요. +- `jwt` — kit이 발급한 bearer 토큰의 `tenant` 클레임을 읽음(토큰이 없으면 `default-tenant-id`로 + 폴백). 외부 OAuth2/OIDC 토큰 검증은 범위 밖 — 그건 커스텀 리졸버로. [멀티테넌시 가이드](../guides/tenancy.md) 참고. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 074dc0a..702bc0f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -33,9 +33,9 @@ are viewable at runtime via `GET /admin/api/v1/settings` (secrets masked). default `X-Tenant-Id`). - `subdomain` — derives it from the request host's subdomain (`acme.example.com` → `acme`). -- `jwt` — _reserved, not yet shipped_: selecting it fails fast at startup (it awaits - the `devslab-kit-oauth2-resource-server-starter`). The login JWT already carries a - `tenant` claim, so resolve it from a custom `TenantResolver` bean meanwhile. +- `jwt` — reads the `tenant` claim from the kit-issued bearer token, falling back to + `default-tenant-id` when there's no token. Validating external OAuth2/OIDC tokens is + out of scope — supply a custom resolver for that. See the [Multi-tenancy guide](../guides/tenancy.md).