fix: make bootstrap admin opt-in and stabilize promotion errors

This commit is contained in:
vsxd 2026-03-16 16:02:42 +08:00 committed by Xudong Sun
parent 2b0bb25240
commit 5168e866da
16 changed files with 79 additions and 46 deletions

View file

@ -40,7 +40,7 @@ SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false
SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY=PT10M
# Bootstrap local admin account for first login. Rotate or disable after initial setup.
BOOTSTRAP_ADMIN_ENABLED=true
BOOTSTRAP_ADMIN_ENABLED=false
BOOTSTRAP_ADMIN_USER_ID=docker-admin
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=replace-this-admin-password

View file

@ -96,8 +96,8 @@ 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`:
The backend can bootstrap a local-login super admin for first-time access
when you explicitly set `BOOTSTRAP_ADMIN_ENABLED=true`:
- username: `BOOTSTRAP_ADMIN_USERNAME` (`admin` by default)
- password: `BOOTSTRAP_ADMIN_PASSWORD` (`ChangeMe!2026` by default)
@ -179,8 +179,9 @@ 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. The backend bootstraps a local admin
account from environment variables for the first login:
It does not enable local mock auth. Bootstrap admin is disabled by default;
if you turn it on explicitly, the backend seeds a local admin account from
environment variables for the first login:
- username: `BOOTSTRAP_ADMIN_USERNAME`
- password: `BOOTSTRAP_ADMIN_PASSWORD`
@ -190,6 +191,7 @@ Recommended production baseline:
- set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint
- keep PostgreSQL / Redis bound to `127.0.0.1`
- use external S3 / OSS via `SKILLHUB_STORAGE_S3_*`
- keep `BOOTSTRAP_ADMIN_ENABLED=false` unless you intentionally need bootstrap login
- rotate or disable the bootstrap admin after initial setup
- run `make validate-release-config` before `docker compose up -d`

View file

@ -56,7 +56,7 @@ services:
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false}
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false}
SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M}
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-true}
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false}
BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-ChangeMe!2026}

View file

@ -47,23 +47,25 @@
| Profile | 用途 | 说明 |
|---------|------|------|
| `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 |
| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不影响通用首登管理员初始化 |
| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不会自动打开首登管理员 |
单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下:
- 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力
- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile
- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile,且默认关闭
- 数据库、Redis、OSS、站点公网地址全部改为环境变量优先
默认首登账号来源于环境变量:
如需启用首登管理员,来源于以下环境变量:
- `BOOTSTRAP_ADMIN_ENABLED=true`
- `BOOTSTRAP_ADMIN_USERNAME`
- `BOOTSTRAP_ADMIN_PASSWORD`
建议:
- 默认保持 `BOOTSTRAP_ADMIN_ENABLED=false`
- 完成首次登录后立即修改管理员密码
- 如果已有外部身份源,可将 `BOOTSTRAP_ADMIN_ENABLED=false`
- 如果已有外部身份源,通常不需要启用 bootstrap admin
- `SKILLHUB_PUBLIC_BASE_URL` 应配置为最终 HTTPS 域名,避免 OAuth / Cookie / 设备码链接异常
## 4 开发环境
@ -206,7 +208,8 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- `SKILLHUB_PUBLIC_BASE_URL` 填最终 HTTPS 域名,且不要带尾部 `/`
- `SKILLHUB_STORAGE_PROVIDER=s3`
- 按云厂商 OSS / S3 兼容参数填写 `SKILLHUB_STORAGE_S3_*`
- 设置非默认的 `POSTGRES_PASSWORD``BOOTSTRAP_ADMIN_PASSWORD`
- 设置非默认的 `POSTGRES_PASSWORD`
- 如果要启用首登管理员,再额外设置 `BOOTSTRAP_ADMIN_ENABLED=true` 与非默认的 `BOOTSTRAP_ADMIN_PASSWORD`
3. 启动前校验
- 运行 `make validate-release-config`
- 确认没有 `replace-me``change-this-*``ChangeMe!2026` 之类的占位值
@ -215,7 +218,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- 检查 `docker compose --env-file .env.release -f compose.release.yml ps`
- 检查 `curl -i http://127.0.0.1:8080/actuator/health`
5. 首登收尾
- 使用 `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` 登录
- 仅在启用了 `BOOTSTRAP_ADMIN_ENABLED=true` 时,使用 `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` 登录
- 立即修改管理员密码
- 如果后续完全走 OAuth可将 `BOOTSTRAP_ADMIN_ENABLED=false`

View file

@ -56,7 +56,7 @@ SkillHub 通过环境变量进行配置,主要配置项如下:
| 环境变量 | 说明 | 默认值 |
|---------|------|--------|
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `true` |
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `false` |
| `BOOTSTRAP_ADMIN_USERNAME` | 首登管理员用户名 | - |
| `BOOTSTRAP_ADMIN_PASSWORD` | 首登管理员密码 | - |

View file

@ -56,7 +56,7 @@ SkillHub is configured through environment variables. The main configuration ite
| Environment Variable | Description | Default Value |
|---------------------|-------------|---------------|
| `BOOTSTRAP_ADMIN_ENABLED` | Enable bootstrap admin | `true` |
| `BOOTSTRAP_ADMIN_ENABLED` | Enable bootstrap admin | `false` |
| `BOOTSTRAP_ADMIN_USERNAME` | Bootstrap admin username | - |
| `BOOTSTRAP_ADMIN_PASSWORD` | Bootstrap admin password | - |

View file

@ -6,7 +6,7 @@ import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "skillhub.bootstrap.admin")
public class BootstrapAdminProperties {
private boolean enabled = true;
private boolean enabled = false;
private String userId = "docker-admin";
private String username = "admin";
private String password = "ChangeMe!2026";

View file

@ -101,7 +101,7 @@ skillhub:
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device}
bootstrap:
admin:
enabled: ${BOOTSTRAP_ADMIN_ENABLED:true}
enabled: ${BOOTSTRAP_ADMIN_ENABLED:false}
user-id: ${BOOTSTRAP_ADMIN_USER_ID:docker-admin}
username: ${BOOTSTRAP_ADMIN_USERNAME:admin}
password: ${BOOTSTRAP_ADMIN_PASSWORD:ChangeMe!2026}

View file

@ -29,6 +29,7 @@ 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.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -64,6 +65,7 @@ class BootstrapAdminInitializerTest {
@Test
void shouldSeedBootstrapAdminWithCredentialRoleAndMembership() throws Exception {
bootstrapAdminProperties.setEnabled(true);
Namespace global = new Namespace("global", "Global", "system");
setField(global, "id", 1L);
@ -83,10 +85,11 @@ class BootstrapAdminInitializerTest {
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());
verify(userAccountRepository, atLeastOnce()).save(userCaptor.capture());
UserAccount savedUser = userCaptor.getAllValues().getLast();
assertEquals("docker-admin", savedUser.getId());
assertEquals("Admin", savedUser.getDisplayName());
assertEquals("admin@skillhub.local", savedUser.getEmail());
ArgumentCaptor<LocalCredential> credentialCaptor = ArgumentCaptor.forClass(LocalCredential.class);
verify(localCredentialRepository).save(credentialCaptor.capture());
@ -107,6 +110,7 @@ class BootstrapAdminInitializerTest {
@Test
void shouldSkipWhenBootstrapAdminCredentialAlreadyExists() {
bootstrapAdminProperties.setEnabled(true);
when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(true);
initializer.run(new DefaultApplicationArguments(new String[0]));

View file

@ -120,17 +120,17 @@ async function unwrap<T>(promise: Promise<{ data?: T; error?: unknown; response:
const envelope = isApiEnvelope<T>(data) ? data : isApiEnvelope<T>(error) ? error : null
if (!response.ok) {
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg)
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg)
}
if (error) {
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg)
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg)
}
if (data === undefined) {
throw new ApiError(`HTTP ${response.status}`, response.status)
}
if (isApiEnvelope<T>(data)) {
if (data.code !== 0) {
throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg)
throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg, data.msg)
}
return data.data
}
@ -244,7 +244,7 @@ export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestWithT
}
if (!response.ok || json.code !== 0) {
throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg)
throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg, json.msg)
}
return json.data
@ -648,7 +648,7 @@ export const tokenApi = {
const envelope = (error && isApiEnvelope<void>(error) ? error : null) as { msg?: string } | null
if (!response.ok || error) {
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg)
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg)
}
},
}

View file

@ -263,6 +263,8 @@
"promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.",
"promotionDuplicateTitle": "Promotion already pending",
"promotionDuplicateDescription": "This version already has a pending promotion request.",
"promotionAlreadyPromotedTitle": "Skill already promoted",
"promotionAlreadyPromotedDescription": "This skill has already been promoted to the global namespace.",
"promotionErrorTitle": "Failed to submit promotion request",
"emptyTitle": "No skills yet",
"emptyDescription": "Start publishing your first skill",
@ -593,6 +595,8 @@
"promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.",
"promotionDuplicateTitle": "Promotion already pending",
"promotionDuplicateDescription": "This version already has a pending promotion request.",
"promotionAlreadyPromotedTitle": "Skill already promoted",
"promotionAlreadyPromotedDescription": "This skill has already been promoted to the global namespace.",
"promotionErrorTitle": "Failed to submit promotion request",
"reportSkill": "Report Skill",
"reportDialogTitle": "Report skill",

View file

@ -263,6 +263,8 @@
"promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。",
"promotionDuplicateTitle": "已存在待处理提升申请",
"promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。",
"promotionAlreadyPromotedTitle": "技能已完成提升",
"promotionAlreadyPromotedDescription": "这个技能已经提升到全局命名空间,无需重复提交。",
"promotionErrorTitle": "提交提升申请失败",
"emptyTitle": "还没有技能",
"emptyDescription": "开始发布你的第一个技能吧",
@ -593,6 +595,8 @@
"promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。",
"promotionDuplicateTitle": "已存在待处理提升申请",
"promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。",
"promotionAlreadyPromotedTitle": "技能已完成提升",
"promotionAlreadyPromotedDescription": "这个技能已经提升到全局命名空间,无需重复提交。",
"promotionErrorTitle": "提交提升申请失败",
"reportSkill": "举报技能",
"reportDialogTitle": "举报技能",

View file

@ -11,14 +11,14 @@ import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
function isDuplicatePromotionMessage(message?: string): boolean {
if (!message) {
return false
function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null {
if (error.serverMessageKey === 'promotion.duplicate_pending') {
return 'promotion.duplicate_pending'
}
return message.includes('promotion.duplicate_pending')
|| message.includes('已有待处理的提升申请')
|| message.includes('Duplicate pending promotion')
if (error.serverMessageKey === 'promotion.already_promoted') {
return 'promotion.already_promoted'
}
return null
}
export function MySkillsPage() {
@ -140,9 +140,16 @@ export function MySkillsPage() {
)
setPromotionTarget(null)
} catch (error) {
if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) {
toast.error(t('mySkills.promotionDuplicateTitle'), t('mySkills.promotionDuplicateDescription'))
return
if (error instanceof ApiError) {
const conflictKey = getPromotionConflictKey(error)
if (conflictKey === 'promotion.duplicate_pending') {
toast.error(t('mySkills.promotionDuplicateTitle'), t('mySkills.promotionDuplicateDescription'))
return
}
if (conflictKey === 'promotion.already_promoted') {
toast.error(t('mySkills.promotionAlreadyPromotedTitle'), t('mySkills.promotionAlreadyPromotedDescription'))
return
}
}
toast.error(t('mySkills.promotionErrorTitle'), error instanceof Error ? error.message : '')
throw error

View file

@ -58,14 +58,14 @@ function parseMetadataJson(parsed?: string) {
}
}
function isDuplicatePromotionMessage(message?: string): boolean {
if (!message) {
return false
function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null {
if (error.serverMessageKey === 'promotion.duplicate_pending') {
return 'promotion.duplicate_pending'
}
return message.includes('promotion.duplicate_pending')
|| message.includes('已有待处理的提升申请')
|| message.includes('Duplicate pending promotion')
if (error.serverMessageKey === 'promotion.already_promoted') {
return 'promotion.already_promoted'
}
return null
}
export function SkillDetailPage() {
@ -363,9 +363,16 @@ export function SkillDetailPage() {
)
setPromotionConfirmOpen(false)
} catch (error) {
if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) {
toast.error(t('skillDetail.promotionDuplicateTitle'), t('skillDetail.promotionDuplicateDescription'))
return
if (error instanceof ApiError) {
const conflictKey = getPromotionConflictKey(error)
if (conflictKey === 'promotion.duplicate_pending') {
toast.error(t('skillDetail.promotionDuplicateTitle'), t('skillDetail.promotionDuplicateDescription'))
return
}
if (conflictKey === 'promotion.already_promoted') {
toast.error(t('skillDetail.promotionAlreadyPromotedTitle'), t('skillDetail.promotionAlreadyPromotedDescription'))
return
}
}
toast.error(t('skillDetail.promotionErrorTitle'), error instanceof Error ? error.message : '')
throw error

View file

@ -223,7 +223,7 @@ export function useSubmitPromotion() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['promotions'] })
queryClient.invalidateQueries({ queryKey: ['governance'] })
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
},
})
}

View file

@ -14,10 +14,12 @@ export class ApiError extends Error {
message: string,
public status: number,
public serverMessage?: string,
public serverMessageKey?: string,
) {
super(resolveLocalizedMessage(message) || message)
this.name = 'ApiError'
this.serverMessage = resolveLocalizedMessage(serverMessage) || serverMessage
this.serverMessageKey = serverMessage
}
}