feat: bootstrap default super admin across profiles

This commit is contained in:
yun-zhi-ztl 2026-03-16 15:06:08 +08:00 • committed by Xudong Sun
parent 556e65efef
commit 0b85bf1e89
4 changed files with 159 additions and 20 deletions

View file

@ -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`

View file

@ -47,12 +47,12 @@
| Profile | 用途 | 说明 |
|---------|------|------|
| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 |
| `docker` | 容器运行时能力 | 启用容器内启动用管理员账号初始化等运行时行为 |
| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不影响通用首登管理员初始化 |
单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下:
- 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力
- 容器环境仍然可以通过 `docker` profile 初始化首个管理员账户
- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile
- 数据库、Redis、OSS、站点公网地址全部改为环境变量优先
默认首登账号来源于环境变量:

View file

@ -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());
}
}

View file

@ -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<UserAccount> 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<LocalCredential> 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<UserRoleBinding> roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class);
verify(userRoleBindingRepository).save(roleBindingCaptor.capture());
assertEquals("docker-admin", roleBindingCaptor.getValue().getUserId());
assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode());
ArgumentCaptor<NamespaceMember> 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);
}
}