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-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/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/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/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/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 8075a21..0692863 100644
--- a/docs/reference/configuration.ko.md
+++ b/docs/reference/configuration.ko.md
@@ -29,8 +29,9 @@
- `fixed` — 항상 `default-tenant-id` 반환. `single` 모드의 자연스러운 선택.
- `header` — 요청 헤더(`header` 속성, 기본 `X-Tenant-Id`)에서 테넌트 id를 읽음.
-- `jwt` — 인증된 JWT의 클레임에서 테넌트를 읽음.
- `subdomain` — 요청 호스트의 서브도메인에서 유도(`acme.example.com` → `acme`).
+- `jwt` — kit이 발급한 bearer 토큰의 `tenant` 클레임을 읽음(토큰이 없으면 `default-tenant-id`로
+ 폴백). 외부 OAuth2/OIDC 토큰 검증은 범위 밖 — 그건 커스텀 리졸버로.
[멀티테넌시 가이드](../guides/tenancy.md) 참고.
@@ -95,6 +96,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 +141,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 +152,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..702bc0f 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` — 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).
@@ -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: