fix(backend): bootstrap admin from env instead of seeding admin/admin

007_seed_data.sql shipped a fixed admin account whose bcrypt hash decodes
to the password "admin", giving every deployment the same known
credentials. The seed row is removed; UserService.EnsureAdmin now creates
the administrator on startup from ADMIN_USERNAME / ADMIN_PASSWORD. It is
idempotent and never overwrites an existing password, so an operator who
rotates the admin password keeps it across restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:01:48 +03:00
parent 945df7ef8a
commit 9ea939ccf6
6 changed files with 56 additions and 3 deletions
+5
View File
@@ -11,6 +11,11 @@ JWT_SECRET=change-me-to-a-random-32-byte-secret
JWT_ACCESS_TTL=15m JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h JWT_REFRESH_TTL=720h
# Initial administrator, created on first startup if it does not yet exist.
# Changing the password later (via the API) is preserved across restarts.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-before-first-run
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Database # Database
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+6
View File
@@ -94,6 +94,12 @@ func main() {
) )
userSvc := service.NewUserService(userRepo, auditSvc) userSvc := service.NewUserService(userRepo, auditSvc)
// Bootstrap the initial administrator (idempotent).
if err := userSvc.EnsureAdmin(context.Background(), cfg.AdminUsername, cfg.AdminPassword); err != nil {
slog.Error("failed to bootstrap admin user", "err", err)
os.Exit(1)
}
// Handlers // Handlers
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
+7
View File
@@ -18,6 +18,10 @@ type Config struct {
JWTAccessTTL time.Duration JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration JWTRefreshTTL time.Duration
// Initial admin bootstrap (applied on startup if the user does not exist)
AdminUsername string
AdminPassword string
// Database // Database
DatabaseURL string DatabaseURL string
@@ -87,6 +91,9 @@ func Load() (*Config, error) {
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"), JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"), JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
AdminPassword: requireStr("ADMIN_PASSWORD"),
DatabaseURL: requireStr("DATABASE_URL"), DatabaseURL: requireStr("DATABASE_URL"),
FilesPath: requireStr("FILES_PATH"), FilesPath: requireStr("FILES_PATH"),
@@ -133,6 +133,10 @@ func setupSuite(t *testing.T) *harness {
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir) fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
userSvc := service.NewUserService(userRepo, auditSvc) userSvc := service.NewUserService(userRepo, auditSvc)
// Bootstrap the admin account the suite logs in with (replaces the old
// hardcoded seed credentials).
require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin"))
// --- Handlers ------------------------------------------------------------ // --- Handlers ------------------------------------------------------------
authMiddleware := handler.NewAuthMiddleware(authSvc) authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc) authHandler := handler.NewAuthHandler(authSvc)
+31
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -21,6 +22,36 @@ func NewUserService(users port.UserRepo, audit *AuditService) *UserService {
return &UserService{users: users, audit: audit} return &UserService{users: users, audit: audit}
} }
// EnsureAdmin creates the initial administrator account if it does not already
// exist. It is idempotent and never overwrites an existing user's password, so
// an operator who has changed the admin password keeps it across restarts.
func (s *UserService) EnsureAdmin(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("EnsureAdmin: username and password must be set")
}
if _, err := s.users.GetByName(ctx, username); err == nil {
return nil // already exists
} else if !errors.Is(err, domain.ErrNotFound) {
return fmt.Errorf("EnsureAdmin: lookup: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("EnsureAdmin: hash: %w", err)
}
_, err = s.users.Create(ctx, &domain.User{
Name: username,
Password: string(hash),
IsAdmin: true,
CanCreate: true,
})
if err != nil {
return fmt.Errorf("EnsureAdmin: create: %w", err)
}
return nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Self-service // Self-service
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+3 -3
View File
@@ -38,12 +38,12 @@ INSERT INTO activity.action_types (name) VALUES
-- Sessions -- Sessions
('session_terminate'); ('session_terminate');
INSERT INTO core.users (name, password, is_admin, can_create) VALUES -- The initial administrator is created at application startup from the
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true); -- ADMIN_USERNAME / ADMIN_PASSWORD environment variables (see UserService.
-- EnsureAdmin), so no default credentials are seeded here.
-- +goose Down -- +goose Down
DELETE FROM core.users WHERE name = 'admin';
DELETE FROM activity.action_types; DELETE FROM activity.action_types;
DELETE FROM core.object_types; DELETE FROM core.object_types;
DELETE FROM core.mime_types; DELETE FROM core.mime_types;