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
+7
View File
@@ -18,6 +18,10 @@ type Config struct {
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
// Initial admin bootstrap (applied on startup if the user does not exist)
AdminUsername string
AdminPassword string
// Database
DatabaseURL string
@@ -87,6 +91,9 @@ func Load() (*Config, error) {
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
AdminPassword: requireStr("ADMIN_PASSWORD"),
DatabaseURL: requireStr("DATABASE_URL"),
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)
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 ------------------------------------------------------------
authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc)
+31
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
@@ -21,6 +22,36 @@ func NewUserService(users port.UserRepo, audit *AuditService) *UserService {
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
// ---------------------------------------------------------------------------