feat: add config-driven super-admin bootstrap to Go backend
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Task: Implement config-driven super-admin bootstrap in Go
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260814-go-bootstrap-admin-6e2b7d9c
|
||||
- Mode: Feature
|
||||
- Branch: main
|
||||
- Worktree: /Users/brother7/Documents/AI/NianAIGC
|
||||
- Base commit: 8affa4b25a93515547a5412d0cbd79dd9fcdc034
|
||||
- Owner: dsh
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Add config-driven first-super-administrator bootstrap to the Go backend, replacing the previous `scripts/bootstrap-admin.mjs` convention with startup-time creation from environment configuration.
|
||||
- Human direction (2026-08-14): bootstrap credentials come from configuration, not a script or CLI step; there is no legacy account import requirement; production is a first deployment of the Go stack, not a cutover from a live Next.js deployment.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Bootstrap only when the backend is PostgreSQL; local development keeps the seeded demo administrator.
|
||||
- Create exactly once: skip when any super administrator exists, including disabled ones.
|
||||
- Fail application startup on a misconfigured bootstrap (invalid phone, password shorter than 8 characters, database error) so the problem is visible instead of silently missing.
|
||||
- Reuse the existing `administration.Service` validation, role model, and password hashing; no new store surface.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added `backend/internal/application/bootstrap_admin.go`:
|
||||
- `BootstrapAdminConfig` with `Configured()`.
|
||||
- `ParseBootstrapAdminConfig(getenv)` reading `ZHINIAN_BOOTSTRAP_ADMIN_PHONE`, `ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD`, `ZHINIAN_BOOTSTRAP_ADMIN_NAME` (default `平台超级管理员`), mirroring the previous script variable names.
|
||||
- `BootstrapSuperAdmin(ctx, backend, service, config)` performing the idempotent creation.
|
||||
- Wired into `application.New` right after the administration service is composed; a successful bootstrap logs one line, a failure aborts startup with `bootstrap super administrator: ...`.
|
||||
- Added `backend/internal/application/bootstrap_admin_test.go` with an in-memory fake store covering: config parsing/defaults, no-op without config, no-op on local backend, first-bootstrap creation with hashed password, second-run idempotency, skip when a disabled super admin exists, and short-password rejection.
|
||||
- Documented the three variables and the startup behavior in `backend/README.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `CGO_ENABLED=0 go test -count=1 ./...` — all 19 packages PASS (the local plain `go test` crashes with a macOS dyld `missing LC_UUID load command` issue unrelated to this change; the repository runner always uses `CGO_ENABLED=0`).
|
||||
- `CGO_ENABLED=0 go vet ./...` — PASS.
|
||||
- `CGO_ENABLED=0 go build ./cmd/zhinian-api` — PASS.
|
||||
- `CGO_ENABLED=0 go test -race -count=1 ./internal/application/ -run Bootstrap` — PASS.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Production schema initialization now happens by manually executed SQL instead of the ACK migration Job pod; see the 2026-08-14 human direction. Deployment guidance and canonical memory reconciliation for that change are tracked in the follow-up integration task.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None; no canonical document changes in this task.
|
||||
@@ -50,6 +50,23 @@ restart; it is intended only for development and contract smoke tests. The
|
||||
default demo identity is the same optional-auth super administrator used by the
|
||||
current Next development flow.
|
||||
|
||||
## First super administrator
|
||||
|
||||
On the first PostgreSQL deployment, the first super administrator is created
|
||||
from configuration at startup — no separate script or CLI step is needed. When
|
||||
the three variables below are all present and the process runs against
|
||||
PostgreSQL, startup creates the account exactly once and skips the bootstrap
|
||||
when any super administrator (including a disabled one) already exists:
|
||||
|
||||
- `ZHINIAN_BOOTSTRAP_ADMIN_PHONE`
|
||||
- `ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD` (minimum 8 characters)
|
||||
- `ZHINIAN_BOOTSTRAP_ADMIN_NAME` (defaults to `平台超级管理员`)
|
||||
|
||||
A failed bootstrap (invalid phone, short password, database error) fails
|
||||
process startup so a misconfigured bootstrap is visible instead of silently
|
||||
missing. Local development mode keeps the seeded demo administrator and never
|
||||
bootstrap-creates accounts.
|
||||
|
||||
No Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go yet, so
|
||||
Next.js remains the deployed owner of every route. Real RDS/CA, OSS, provider,
|
||||
Webhook, Worker drain/recovery, and rollback validation are mandatory before
|
||||
|
||||
@@ -3,6 +3,8 @@ package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -176,6 +178,11 @@ func New(options Options) (*App, error) {
|
||||
})
|
||||
|
||||
administrationService := administration.NewService(administrationStore)
|
||||
if created, err := BootstrapSuperAdmin(ctx, config.Backend, administrationService, ParseBootstrapAdminConfig(getenv)); err != nil {
|
||||
return nil, fmt.Errorf("bootstrap super administrator: %w", err)
|
||||
} else if created {
|
||||
log.Printf("zhinian-api bootstrapped the first super administrator from ZHINIAN_BOOTSTRAP_ADMIN_* configuration")
|
||||
}
|
||||
adminHandler, err := httpapi.NewAdminHandler(platformAuthorizer, administrationService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
67
backend/internal/application/bootstrap_admin.go
Normal file
67
backend/internal/application/bootstrap_admin.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
|
||||
)
|
||||
|
||||
// BootstrapAdminConfig describes the config-driven first super administrator.
|
||||
// When every field is present and the process runs against PostgreSQL,
|
||||
// application startup creates this account exactly once and skips the
|
||||
// bootstrap when a super administrator already exists.
|
||||
type BootstrapAdminConfig struct {
|
||||
Phone string
|
||||
Password string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// Configured reports whether every bootstrap field is present.
|
||||
func (c BootstrapAdminConfig) Configured() bool {
|
||||
return c.Phone != "" && c.Password != "" && c.DisplayName != ""
|
||||
}
|
||||
|
||||
// ParseBootstrapAdminConfig reads ZHINIAN_BOOTSTRAP_ADMIN_PHONE,
|
||||
// ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD, and ZHINIAN_BOOTSTRAP_ADMIN_NAME. The
|
||||
// variable names mirror the previous scripts/bootstrap-admin.mjs contract;
|
||||
// the display name defaults to 平台超级管理员.
|
||||
func ParseBootstrapAdminConfig(getenv func(string) string) BootstrapAdminConfig {
|
||||
if getenv == nil {
|
||||
return BootstrapAdminConfig{}
|
||||
}
|
||||
return BootstrapAdminConfig{
|
||||
Phone: strings.TrimSpace(getenv("ZHINIAN_BOOTSTRAP_ADMIN_PHONE")),
|
||||
Password: getenv("ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
DisplayName: firstNonEmpty(strings.TrimSpace(getenv("ZHINIAN_BOOTSTRAP_ADMIN_NAME")), "平台超级管理员"),
|
||||
}
|
||||
}
|
||||
|
||||
// BootstrapSuperAdmin creates the configured first super administrator once.
|
||||
// It is a no-op when the backend is not PostgreSQL, when the configuration is
|
||||
// incomplete, or when a super administrator already exists (including
|
||||
// disabled ones, so a retired administrator cannot trigger a duplicate).
|
||||
// Failures are returned to the caller and fail application startup, so a
|
||||
// misconfigured bootstrap is visible instead of silently missing.
|
||||
func BootstrapSuperAdmin(ctx context.Context, backend postgres.Backend, service *administration.Service, config BootstrapAdminConfig) (bool, error) {
|
||||
if backend != postgres.BackendPostgres || !config.Configured() {
|
||||
return false, nil
|
||||
}
|
||||
existing, err := service.ListAccounts(ctx, administration.Actor{Role: administration.RoleSuperAdmin}, administration.AccountFilters{Role: administration.RoleSuperAdmin, IncludeDisabled: true})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := service.CreateAccount(ctx, administration.Actor{Role: administration.RoleSuperAdmin}, administration.CreateAccountInput{
|
||||
Phone: config.Phone,
|
||||
DisplayName: config.DisplayName,
|
||||
Password: config.Password,
|
||||
Role: administration.RoleSuperAdmin,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
206
backend/internal/application/bootstrap_admin_test.go
Normal file
206
backend/internal/application/bootstrap_admin_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
|
||||
)
|
||||
|
||||
// bootstrapFakeStore is a minimal in-memory administration.Store for
|
||||
// bootstrap tests. Only ListAccounts and CreateAccount carry behavior; the
|
||||
// remaining methods exist to satisfy the interface.
|
||||
type bootstrapFakeStore struct {
|
||||
accounts map[string]administration.Account
|
||||
}
|
||||
|
||||
func newBootstrapFakeStore() *bootstrapFakeStore {
|
||||
return &bootstrapFakeStore{accounts: map[string]administration.Account{}}
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) ListAccounts(_ context.Context, filters administration.AccountFilters) ([]administration.Account, error) {
|
||||
out := []administration.Account{}
|
||||
for _, account := range s.accounts {
|
||||
if filters.OrganizationID != "" && account.OrganizationID != filters.OrganizationID {
|
||||
continue
|
||||
}
|
||||
if filters.Role != "" && account.Role != filters.Role {
|
||||
continue
|
||||
}
|
||||
if !filters.IncludeDisabled && account.Status != administration.StatusActive {
|
||||
continue
|
||||
}
|
||||
out = append(out, account)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) GetAccount(_ context.Context, id string) (administration.Account, bool, error) {
|
||||
account, ok := s.accounts[id]
|
||||
return account, ok, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) CreateAccount(_ context.Context, account administration.Account) (administration.Account, error) {
|
||||
if _, exists := s.accounts[account.ID]; exists {
|
||||
return administration.Account{}, errors.New("account already exists")
|
||||
}
|
||||
s.accounts[account.ID] = account
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) UpdateAccount(_ context.Context, account administration.Account) (administration.Account, error) {
|
||||
s.accounts[account.ID] = account
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) DeleteAccount(_ context.Context, id, _ string) error {
|
||||
delete(s.accounts, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) ListOrganizations(context.Context, bool) ([]administration.Organization, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) GetOrganization(context.Context, string) (administration.Organization, bool, error) {
|
||||
return administration.Organization{}, false, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) CreateOrganization(_ context.Context, organization administration.Organization) (administration.Organization, error) {
|
||||
return organization, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) UpdateOrganization(_ context.Context, organization administration.Organization) (administration.Organization, error) {
|
||||
return organization, nil
|
||||
}
|
||||
|
||||
func (s *bootstrapFakeStore) DeleteOrganization(context.Context, string) error { return nil }
|
||||
|
||||
func (s *bootstrapFakeStore) CountOrganizationMembers(context.Context, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func configuredBootstrap() BootstrapAdminConfig {
|
||||
return BootstrapAdminConfig{Phone: "13800138000", Password: "change-me-now", DisplayName: "平台超级管理员"}
|
||||
}
|
||||
|
||||
func TestParseBootstrapAdminConfig(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"ZHINIAN_BOOTSTRAP_ADMIN_PHONE": " 13800138000 ",
|
||||
"ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD": "change-me-now",
|
||||
"ZHINIAN_BOOTSTRAP_ADMIN_NAME": " 平台超级管理员 ",
|
||||
}
|
||||
config := ParseBootstrapAdminConfig(func(name string) string { return env[name] })
|
||||
if config.Phone != "13800138000" || config.Password != "change-me-now" || config.DisplayName != "平台超级管理员" {
|
||||
t.Fatalf("unexpected parsed config: %+v", config)
|
||||
}
|
||||
if !config.Configured() {
|
||||
t.Fatalf("expected parsed config to be complete")
|
||||
}
|
||||
|
||||
defaulted := ParseBootstrapAdminConfig(func(name string) string {
|
||||
if name == "ZHINIAN_BOOTSTRAP_ADMIN_NAME" {
|
||||
return ""
|
||||
}
|
||||
return env[name]
|
||||
})
|
||||
if defaulted.DisplayName != "平台超级管理员" {
|
||||
t.Fatalf("expected default display name, got %q", defaulted.DisplayName)
|
||||
}
|
||||
|
||||
if config := ParseBootstrapAdminConfig(nil); config.Configured() {
|
||||
t.Fatalf("nil getenv must produce an incomplete config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminNoopWithoutConfig(t *testing.T) {
|
||||
store := newBootstrapFakeStore()
|
||||
service := administration.NewService(store)
|
||||
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, BootstrapAdminConfig{})
|
||||
if err != nil || created {
|
||||
t.Fatalf("expected no-op without config, got created=%v err=%v", created, err)
|
||||
}
|
||||
if len(store.accounts) != 0 {
|
||||
t.Fatalf("expected no accounts, got %d", len(store.accounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminNoopOnLocalBackend(t *testing.T) {
|
||||
store := newBootstrapFakeStore()
|
||||
service := administration.NewService(store)
|
||||
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendLocal, service, configuredBootstrap())
|
||||
if err != nil || created {
|
||||
t.Fatalf("expected no-op on local backend, got created=%v err=%v", created, err)
|
||||
}
|
||||
if len(store.accounts) != 0 {
|
||||
t.Fatalf("expected no accounts, got %d", len(store.accounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminCreatesOnce(t *testing.T) {
|
||||
store := newBootstrapFakeStore()
|
||||
service := administration.NewService(store)
|
||||
|
||||
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, configuredBootstrap())
|
||||
if err != nil || !created {
|
||||
t.Fatalf("expected first bootstrap to create, got created=%v err=%v", created, err)
|
||||
}
|
||||
if len(store.accounts) != 1 {
|
||||
t.Fatalf("expected exactly one account, got %d", len(store.accounts))
|
||||
}
|
||||
for _, account := range store.accounts {
|
||||
if account.Role != administration.RoleSuperAdmin || account.Status != administration.StatusActive {
|
||||
t.Fatalf("unexpected bootstrapped account: %+v", account)
|
||||
}
|
||||
if account.PasswordHash == "change-me-now" || account.PasswordHash == "" {
|
||||
t.Fatalf("password must be hashed, got %q", account.PasswordHash)
|
||||
}
|
||||
}
|
||||
|
||||
created, err = BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, configuredBootstrap())
|
||||
if err != nil || created {
|
||||
t.Fatalf("expected second bootstrap to skip, got created=%v err=%v", created, err)
|
||||
}
|
||||
if len(store.accounts) != 1 {
|
||||
t.Fatalf("expected still exactly one account, got %d", len(store.accounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminSkipsDisabledSuperAdmin(t *testing.T) {
|
||||
store := newBootstrapFakeStore()
|
||||
existing := administration.Account{
|
||||
ID: "user-existing", Phone: "13900139000", DisplayName: "已有超管",
|
||||
Role: administration.RoleSuperAdmin, Status: administration.StatusDisabled,
|
||||
SessionVersion: 1,
|
||||
}
|
||||
store.accounts[existing.ID] = existing
|
||||
service := administration.NewService(store)
|
||||
|
||||
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, configuredBootstrap())
|
||||
if err != nil || created {
|
||||
t.Fatalf("expected skip with existing disabled super admin, got created=%v err=%v", created, err)
|
||||
}
|
||||
if len(store.accounts) != 1 {
|
||||
t.Fatalf("expected no new account, got %d", len(store.accounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminRejectsShortPassword(t *testing.T) {
|
||||
store := newBootstrapFakeStore()
|
||||
service := administration.NewService(store)
|
||||
config := configuredBootstrap()
|
||||
config.Password = "short"
|
||||
|
||||
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, config)
|
||||
if err == nil {
|
||||
t.Fatalf("expected short password to fail")
|
||||
}
|
||||
if created {
|
||||
t.Fatalf("expected no account on failed bootstrap")
|
||||
}
|
||||
if len(store.accounts) != 0 {
|
||||
t.Fatalf("expected no accounts, got %d", len(store.accounts))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user