Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ 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) 참고.
- **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

### Added
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,35 @@ 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).
- **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

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -72,7 +72,7 @@ implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2")
<dependency>
<groupId>kr.devslab</groupId>
<artifactId>devslab-kit-spring-boot-starter</artifactId>
<version>0.4.2</version>
<version>0.5.0</version>
</dependency>
```

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -74,7 +74,7 @@ implementation("kr.devslab:devslab-kit-spring-boot-starter:0.4.2")
<dependency>
<groupId>kr.devslab</groupId>
<artifactId>devslab-kit-spring-boot-starter</artifactId>
<version>0.4.2</version>
<version>0.5.0</version>
</dependency>
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
Expand All @@ -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<String, List<String>> entry : seed.getRoles().entrySet()) {
String roleCode = entry.getKey();
if (roleCode == null || roleCode.isBlank()) {
continue;
}
RoleId role = ensureRoleId(tenant, roleCode.trim(), roleCode.trim());
List<String> 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());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
* <strong>additive only</strong> — 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.
*
* <pre>
* 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]
* </pre>
*
* <p>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<String> permissions = new ArrayList<>();

/** Role code &rarr; the permission codes that role should grant. */
private Map<String, List<String>> roles = new LinkedHashMap<>();

public List<String> getPermissions() {
return permissions;
}

public void setPermissions(List<String> permissions) {
this.permissions = permissions;
}

public Map<String, List<String>> getRoles() {
return roles;
}

public void setRoles(Map<String, List<String>> roles) {
this.roles = roles;
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,16 +42,25 @@ public TenantContextHolder tenantContextHolder() {

@Bean
@ConditionalOnMissingBean
public TenantResolver tenantResolver(DevslabKitProperties properties) {
public TenantResolver tenantResolver(
DevslabKitProperties properties,
ObjectProvider<AuthTokenService> 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."
Expand Down
Loading
Loading