From 9ea939ccf661db09096a3a98430fbe875eed8e6b Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Wed, 10 Jun 2026 14:01:48 +0300 Subject: [PATCH] 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 --- .env.example | 5 ++++ backend/cmd/server/main.go | 6 ++++ backend/internal/config/config.go | 7 +++++ backend/internal/integration/server_test.go | 4 +++ backend/internal/service/user_service.go | 31 +++++++++++++++++++++ backend/migrations/007_seed_data.sql | 6 ++-- 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index da6485b..45dc87a 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ JWT_SECRET=change-me-to-a-random-32-byte-secret JWT_ACCESS_TTL=15m 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 # --------------------------------------------------------------------------- diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3ca8256..267ed91 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -94,6 +94,12 @@ func main() { ) 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 authMiddleware := handler.NewAuthMiddleware(authSvc) authHandler := handler.NewAuthHandler(authSvc) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 944720a..cf990ac 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index 77aaba3..83283de 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -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) diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 923ea16..1e899f2 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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 // --------------------------------------------------------------------------- diff --git a/backend/migrations/007_seed_data.sql b/backend/migrations/007_seed_data.sql index 720c8c1..8b3373f 100644 --- a/backend/migrations/007_seed_data.sql +++ b/backend/migrations/007_seed_data.sql @@ -38,12 +38,12 @@ INSERT INTO activity.action_types (name) VALUES -- Sessions ('session_terminate'); -INSERT INTO core.users (name, password, is_admin, can_create) VALUES - ('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true); +-- The initial administrator is created at application startup from the +-- ADMIN_USERNAME / ADMIN_PASSWORD environment variables (see UserService. +-- EnsureAdmin), so no default credentials are seeded here. -- +goose Down -DELETE FROM core.users WHERE name = 'admin'; DELETE FROM activity.action_types; DELETE FROM core.object_types; DELETE FROM core.mime_types;