From 0b85bf1e89ea3a458afd4498b65b43a6d8c52ca1 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 15:06:08 +0800 Subject: [PATCH] feat: bootstrap default super admin across profiles --- README.md | 10 +- docs/09-deployment.md | 4 +- ...er.java => BootstrapAdminInitializer.java} | 30 ++-- .../BootstrapAdminInitializerTest.java | 135 ++++++++++++++++++ 4 files changed, 159 insertions(+), 20 deletions(-) rename server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/{DockerSeedDataRunner.java => BootstrapAdminInitializer.java} (80%) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java diff --git a/README.md b/README.md index 9017795f..a7bade3d 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ Local profile seeds two mock-auth users automatically: Use them with the `X-Mock-User-Id` header in local development. +The backend also bootstraps a local-login super admin for first-time access +in any runtime when `BOOTSTRAP_ADMIN_ENABLED=true`: + +- username: `BOOTSTRAP_ADMIN_USERNAME` (`admin` by default) +- password: `BOOTSTRAP_ADMIN_PASSWORD` (`ChangeMe!2026` by default) + Stop everything with: ```bash @@ -173,8 +179,8 @@ The runtime stack uses its own Compose project name, so it does not collide with containers from `make dev-all`. The production Compose stack now defaults to the `docker` profile only. -It does not enable local mock auth. Instead, the backend bootstraps a -local admin account from environment variables for the first login: +It does not enable local mock auth. The backend bootstraps a local admin +account from environment variables for the first login: - username: `BOOTSTRAP_ADMIN_USERNAME` - password: `BOOTSTRAP_ADMIN_PASSWORD` diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 52e4849e..20bf73c6 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -47,12 +47,12 @@ | Profile | 用途 | 说明 | |---------|------|------| | `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | -| `docker` | 容器运行时能力 | 启用容器内启动用管理员账号初始化等运行时行为 | +| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不影响通用首登管理员初始化 | 单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下: - 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力 -- 容器环境仍然可以通过 `docker` profile 初始化首个管理员账户 +- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile - 数据库、Redis、OSS、站点公网地址全部改为环境变量优先 默认首登账号来源于环境变量: diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java similarity index 80% rename from server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java rename to server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java index 6caf75ed..f8e7fab5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java @@ -17,19 +17,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** - * Seeds default admin account for Docker one-click startup. + * Seeds a default bootstrap admin account for any runtime profile. * Idempotent: skips if admin credential already exists. */ @Component -@Profile("docker") -public class DockerSeedDataRunner implements ApplicationRunner { - private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class); +public class BootstrapAdminInitializer implements ApplicationRunner { + private static final Logger log = LoggerFactory.getLogger(BootstrapAdminInitializer.class); private final BootstrapAdminProperties bootstrapAdminProperties; private final UserAccountRepository userAccountRepository; @@ -40,14 +38,14 @@ public class DockerSeedDataRunner implements ApplicationRunner { private final NamespaceMemberRepository namespaceMemberRepository; private final PasswordEncoder passwordEncoder; - public DockerSeedDataRunner(BootstrapAdminProperties bootstrapAdminProperties, - UserAccountRepository userAccountRepository, - LocalCredentialRepository localCredentialRepository, - RoleRepository roleRepository, - UserRoleBindingRepository userRoleBindingRepository, - NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository, - PasswordEncoder passwordEncoder) { + public BootstrapAdminInitializer(BootstrapAdminProperties bootstrapAdminProperties, + UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { this.bootstrapAdminProperties = bootstrapAdminProperties; this.userAccountRepository = userAccountRepository; this.localCredentialRepository = localCredentialRepository; @@ -62,11 +60,11 @@ public class DockerSeedDataRunner implements ApplicationRunner { @Transactional public void run(ApplicationArguments args) { if (!bootstrapAdminProperties.isEnabled()) { - log.info("Docker bootstrap admin is disabled"); + log.info("Bootstrap admin is disabled"); return; } if (localCredentialRepository.existsByUsernameIgnoreCase(bootstrapAdminProperties.getUsername())) { - log.info("Docker seed data already exists, skipping"); + log.info("Bootstrap admin already exists, skipping"); return; } @@ -109,6 +107,6 @@ public class DockerSeedDataRunner implements ApplicationRunner { namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER)); } - log.info("Docker seed data initialized for admin account: {}", bootstrapAdminProperties.getUsername()); + log.info("Bootstrap admin initialized for account: {}", bootstrapAdminProperties.getUsername()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java new file mode 100644 index 00000000..4615019d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java @@ -0,0 +1,135 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BootstrapAdminInitializerTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private LocalCredentialRepository localCredentialRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleBindingRepository userRoleBindingRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private PasswordEncoder passwordEncoder; + + private BootstrapAdminProperties bootstrapAdminProperties; + private BootstrapAdminInitializer initializer; + + @BeforeEach + void setUp() { + bootstrapAdminProperties = new BootstrapAdminProperties(); + initializer = new BootstrapAdminInitializer( + bootstrapAdminProperties, + userAccountRepository, + localCredentialRepository, + roleRepository, + userRoleBindingRepository, + namespaceRepository, + namespaceMemberRepository, + passwordEncoder + ); + } + + @Test + void shouldSeedBootstrapAdminWithCredentialRoleAndMembership() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setField(global, "id", 1L); + + Role superAdminRole = new Role(); + setField(superAdminRole, "id", 1L); + setField(superAdminRole, "code", "SUPER_ADMIN"); + + when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(false); + when(userAccountRepository.findById("docker-admin")).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(passwordEncoder.encode("ChangeMe!2026")).thenReturn("encoded-password"); + when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole)); + when(userRoleBindingRepository.findByUserId("docker-admin")).thenReturn(List.of()); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "docker-admin")).thenReturn(Optional.empty()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository).save(userCaptor.capture()); + assertEquals("docker-admin", userCaptor.getValue().getId()); + assertEquals("Admin", userCaptor.getValue().getDisplayName()); + assertEquals("admin@skillhub.local", userCaptor.getValue().getEmail()); + + ArgumentCaptor credentialCaptor = ArgumentCaptor.forClass(LocalCredential.class); + verify(localCredentialRepository).save(credentialCaptor.capture()); + assertEquals("docker-admin", credentialCaptor.getValue().getUserId()); + assertEquals("admin", credentialCaptor.getValue().getUsername()); + assertEquals("encoded-password", credentialCaptor.getValue().getPasswordHash()); + + ArgumentCaptor roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class); + verify(userRoleBindingRepository).save(roleBindingCaptor.capture()); + assertEquals("docker-admin", roleBindingCaptor.getValue().getUserId()); + assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode()); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertEquals("docker-admin", memberCaptor.getValue().getUserId()); + assertEquals(NamespaceRole.OWNER, memberCaptor.getValue().getRole()); + } + + @Test + void shouldSkipWhenBootstrapAdminCredentialAlreadyExists() { + when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(true); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(localCredentialRepository, never()).save(any(LocalCredential.class)); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + verify(namespaceMemberRepository, never()).save(any(NamespaceMember.class)); + } + + @Test + void shouldSkipWhenBootstrapAdminIsDisabled() { + bootstrapAdminProperties.setEnabled(false); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(localCredentialRepository, never()).existsByUsernameIgnoreCase(any()); + verify(userAccountRepository, never()).save(any(UserAccount.class)); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +}