diff --git a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java index e1f7e45..20d1c13 100644 --- a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java +++ b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleApplication.java @@ -1,16 +1,55 @@ package kr.devslab.kit.sample; +import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; +import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; +import kr.devslab.kit.access.core.service.PermissionAdminService; +import kr.devslab.kit.access.core.service.RoleAdminService; +import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; +import kr.devslab.kit.tenant.TenantService; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.AutoConfigurationPackage; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication(scanBasePackages = "kr.devslab.kit") @AutoConfigurationPackage(basePackages = "kr.devslab.kit") @EnableJpaRepositories(basePackages = "kr.devslab.kit") +@EnableConfigurationProperties(SampleSeedProperties.class) public class SampleApplication { public static void main(String[] args) { SpringApplication.run(SampleApplication.class, args); } + + @Bean + SampleSeedRunner sampleSeedRunner( + SampleSeedProperties props, + TenantService tenantService, + PermissionAdminService permissionAdminService, + JpaPlatformPermissionRepository permissionRepository, + RoleAdminService roleAdminService, + JpaPlatformRoleRepository roleRepository, + RolePermissionService rolePermissionService, + PlatformUserAccountAdminService userAdminService, + JpaPlatformUserAccountRepository userRepository, + UserRoleService userRoleService + ) { + return new SampleSeedRunner( + props, + tenantService, + permissionAdminService, + permissionRepository, + roleAdminService, + roleRepository, + rolePermissionService, + userAdminService, + userRepository, + userRoleService + ); + } } diff --git a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedProperties.java b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedProperties.java new file mode 100644 index 0000000..4936a0c --- /dev/null +++ b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedProperties.java @@ -0,0 +1,26 @@ +package kr.devslab.kit.sample; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Settings for {@link SampleSeedRunner}. Only meaningful in the sample + * application — real consumers seed their own data however they like. + */ +@ConfigurationProperties(prefix = "sample.seed") +public class SampleSeedProperties { + + /** Disable for production-like environments where seed data is unwanted. */ + private boolean enabled = true; + private String adminLoginId = "admin"; + private String adminPassword = "admin"; + private String adminEmail = "admin@example.com"; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getAdminLoginId() { return adminLoginId; } + public void setAdminLoginId(String adminLoginId) { this.adminLoginId = adminLoginId; } + public String getAdminPassword() { return adminPassword; } + public void setAdminPassword(String adminPassword) { this.adminPassword = adminPassword; } + public String getAdminEmail() { return adminEmail; } + public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; } +} diff --git a/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedRunner.java b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedRunner.java new file mode 100644 index 0000000..8eff875 --- /dev/null +++ b/devslab-kit-sample-app/src/main/java/kr/devslab/kit/sample/SampleSeedRunner.java @@ -0,0 +1,180 @@ +package kr.devslab.kit.sample; + +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.PermissionAdminService; +import kr.devslab.kit.access.core.service.RoleAdminService; +import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; +import kr.devslab.kit.core.id.PermissionId; +import kr.devslab.kit.core.id.RoleId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; +import kr.devslab.kit.tenant.TenantMetadata; +import kr.devslab.kit.tenant.TenantMode; +import kr.devslab.kit.tenant.TenantService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.transaction.annotation.Transactional; + +/** + * Idempotent first-boot seed for the sample app. + * + *

Provisions just enough for the admin UI to be usable end-to-end against + * this backend: + * + *

+ * + *

Every step is idempotent — repeated boots find each entity by code / + * loginId and skip the create, so re-running against an already-seeded + * database is a no-op. + * + *

Disable with {@code sample.seed.enabled=false} for production-like + * runs where seed data would be unwanted. + */ +public class SampleSeedRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(SampleSeedRunner.class); + + private static final String DEFAULT_TENANT_ID = "default"; + private static final String ADMIN_ROLE_CODE = "PLATFORM_ADMIN"; + private static final String ADMIN_ROLE_NAME = "Platform Admin"; + + private static final List ADMIN_PERMISSIONS = List.of( + new PermissionSeed("admin.user.read", "Read user accounts via /admin/api/v1/users"), + new PermissionSeed("admin.user.write", "Create / update / delete user accounts"), + new PermissionSeed("admin.role.read", "Read roles and their permission grants"), + new PermissionSeed("admin.role.write", "Create / rename / delete roles and grant permissions"), + new PermissionSeed("admin.permission.read", "Read permission catalogue"), + new PermissionSeed("admin.permission.write", "Create / update / delete permissions"), + new PermissionSeed("admin.group.read", "Read groups and memberships"), + new PermissionSeed("admin.group.write", "Create / rename / delete groups and manage memberships"), + new PermissionSeed("admin.menu.read", "Read menu tree"), + new PermissionSeed("admin.menu.write", "Create / update / delete menu items"), + new PermissionSeed("admin.tenant.read", "Read tenants"), + new PermissionSeed("admin.tenant.write", "Create / rename / change status / delete tenants"), + new PermissionSeed("admin.policy.test", "Run policy dry-run via /admin/api/v1/policies/test"), + new PermissionSeed("admin.audit.read", "Read audit logs with filters + paging"), + new PermissionSeed("admin.diagnostics.run", "Run diagnostics probes (login-test, permission-check, menu-visibility)"), + new PermissionSeed("admin.settings.read", "Read DevslabKitProperties via /admin/api/v1/settings") + ); + + private final SampleSeedProperties props; + private final TenantService tenantService; + private final PermissionAdminService permissionAdminService; + private final JpaPlatformPermissionRepository permissionRepository; + private final RoleAdminService roleAdminService; + private final JpaPlatformRoleRepository roleRepository; + private final RolePermissionService rolePermissionService; + private final PlatformUserAccountAdminService userAdminService; + private final JpaPlatformUserAccountRepository userRepository; + private final UserRoleService userRoleService; + + public SampleSeedRunner( + SampleSeedProperties props, + TenantService tenantService, + PermissionAdminService permissionAdminService, + JpaPlatformPermissionRepository permissionRepository, + RoleAdminService roleAdminService, + JpaPlatformRoleRepository roleRepository, + RolePermissionService rolePermissionService, + PlatformUserAccountAdminService userAdminService, + JpaPlatformUserAccountRepository userRepository, + UserRoleService userRoleService + ) { + this.props = props; + this.tenantService = tenantService; + this.permissionAdminService = permissionAdminService; + this.permissionRepository = permissionRepository; + this.roleAdminService = roleAdminService; + this.roleRepository = roleRepository; + this.rolePermissionService = rolePermissionService; + this.userAdminService = userAdminService; + this.userRepository = userRepository; + this.userRoleService = userRoleService; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!props.isEnabled()) { + log.info("[sample-seed] disabled via sample.seed.enabled=false — skipping"); + return; + } + + TenantId tenant = ensureTenant(); + RoleId adminRole = ensureAdminRole(tenant); + ensureAdminPermissionsAndGrants(adminRole); + UserId adminUser = ensureAdminUser(tenant); + userRoleService.assign(adminUser, adminRole, tenant); // idempotent + + log.info("[sample-seed] complete: tenant={} role={} user={} (login as {} / {})", + tenant.value(), ADMIN_ROLE_CODE, adminUser.value(), + props.getAdminLoginId(), props.getAdminPassword()); + } + + private TenantId ensureTenant() { + TenantId id = TenantId.of(DEFAULT_TENANT_ID); + if (tenantService.findById(id).isPresent()) { + return id; + } + TenantMetadata created = tenantService.create(id, "Default Tenant", TenantMode.SINGLE); + log.info("[sample-seed] created tenant {}", created.id().value()); + return id; + } + + private RoleId ensureAdminRole(TenantId tenant) { + return roleRepository.findByTenantIdAndCode(tenant.value(), ADMIN_ROLE_CODE) + .map(entity -> RoleId.of(entity.getId())) + .orElseGet(() -> { + var role = roleAdminService.create(tenant, ADMIN_ROLE_CODE, ADMIN_ROLE_NAME); + log.info("[sample-seed] created role {}", ADMIN_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("[sample-seed] created permission {}", perm.code()); + return permissionRepository.findByCode(perm.code()) + .map(e -> PermissionId.of(e.getId())) + .orElseThrow(); + }); + rolePermissionService.grant(adminRole, permId); // idempotent + } + } + + private UserId ensureAdminUser(TenantId tenant) { + return userRepository.findByTenantIdAndLoginId(tenant.value(), props.getAdminLoginId()) + .map(entity -> UserId.of(entity.getId())) + .orElseGet(() -> { + var view = userAdminService.create( + tenant, + props.getAdminLoginId(), + props.getAdminEmail(), + props.getAdminPassword(), + "LOCAL" + ); + log.info("[sample-seed] created user {}", view.loginId()); + return view.id(); + }); + } + + private record PermissionSeed(String code, String description) { + } +} diff --git a/devslab-kit-sample-app/src/main/resources/application.yaml b/devslab-kit-sample-app/src/main/resources/application.yaml index b922759..4c9ddc6 100644 --- a/devslab-kit-sample-app/src/main/resources/application.yaml +++ b/devslab-kit-sample-app/src/main/resources/application.yaml @@ -2,6 +2,21 @@ spring: application: name: devslab-kit-sample-app + # Connect to the same Postgres / Redis that + # devslab-kit-admin-ui's docker-compose stands up by default. Override the + # DEVSLAB_* env vars when you need to point elsewhere (CI, staging, etc). + datasource: + url: ${DEVSLAB_DATASOURCE_URL:jdbc:postgresql://localhost:5432/devslab_kit} + username: ${DEVSLAB_DATASOURCE_USERNAME:devslab} + password: ${DEVSLAB_DATASOURCE_PASSWORD:devslab} + data: + redis: + host: ${DEVSLAB_REDIS_HOST:localhost} + port: ${DEVSLAB_REDIS_PORT:6379} + + jpa: + open-in-view: false + devslab: kit: tenant: @@ -9,3 +24,30 @@ devslab: mode: single default-tenant-id: default resolver: fixed + + identity: + jwt: + # 32+ byte key required by jjwt HS256. Override in prod via + # DEVSLAB_JWT_SECRET — the default below is sufficient for local + # development and the integration test setup, NOT for production. + secret: ${DEVSLAB_JWT_SECRET:dev-only-32byte-jwt-key-please-override!} + issuer: ${DEVSLAB_JWT_ISSUER:devslab-kit-sample} + ttl: ${DEVSLAB_JWT_TTL:PT8H} + + audit: + enabled: true + async-queue-capacity: ${DEVSLAB_AUDIT_QUEUE:1024} + + menu: + enabled: true + cache-ttl: ${DEVSLAB_MENU_CACHE_TTL:PT5M} + +# Sample-app-only seed runner: provisions a default tenant + admin user with +# PLATFORM_ADMIN role + admin.* permissions on first boot, so the admin UI +# can log in immediately for end-to-end testing. Disable in production. +sample: + seed: + enabled: ${SAMPLE_SEED_ENABLED:true} + admin-login-id: ${SAMPLE_SEED_ADMIN_LOGIN_ID:admin} + admin-password: ${SAMPLE_SEED_ADMIN_PASSWORD:admin} + admin-email: ${SAMPLE_SEED_ADMIN_EMAIL:admin@example.com}