mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
feat: bootstrap default super admin across profiles
This commit is contained in:
parent
556e65efef
commit
0b85bf1e89
4 changed files with 159 additions and 20 deletions
10
README.md
10
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`
|
||||
|
|
|
|||
|
|
@ -47,12 +47,12 @@
|
|||
| Profile | 用途 | 说明 |
|
||||
|---------|------|------|
|
||||
| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 |
|
||||
| `docker` | 容器运行时能力 | 启用容器内启动用管理员账号初始化等运行时行为 |
|
||||
| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不影响通用首登管理员初始化 |
|
||||
|
||||
单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下:
|
||||
|
||||
- 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力
|
||||
- 容器环境仍然可以通过 `docker` profile 初始化首个管理员账户
|
||||
- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile
|
||||
- 数据库、Redis、OSS、站点公网地址全部改为环境变量优先
|
||||
|
||||
默认首登账号来源于环境变量:
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue