feat(users): bootstrap first admin on startup

BOOTSTRAP_ADMIN_EMAIL/PASSWORD seed a role=admin account at boot if absent —
the only way to get the first admin, since /register makes plain users and
promotion needs an existing admin. Idempotent and race-safe. Tests included.
This commit is contained in:
Efremenko Arhip
2026-07-26 19:44:06 +03:00
parent 163cbe14bf
commit a7e949a0a7
5 changed files with 172 additions and 9 deletions
+6
View File
@@ -11,6 +11,12 @@ JWT_TTL=24h
# bcrypt work factor. Empty/0 uses the library default (10).
# BCRYPT_COST=10
# First-admin bootstrap. When both are set and no such account exists, the
# service creates it with role=admin on startup (idempotent). This is the only
# way to get the first admin. Leave empty in production once seeded.
# BOOTSTRAP_ADMIN_EMAIL=root@scimesh.local
# BOOTSTRAP_ADMIN_PASSWORD=change-me-strong
# Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only;
# set a path to also write a size-rotated file (kept across restarts).
LOG_LEVEL=info
+12
View File
@@ -60,6 +60,18 @@ func run() error {
Users: users,
}
// Seed the first admin, if configured. Idempotent: a no-op once it exists.
if cfg.BootstrapAdminEmail != "" && cfg.BootstrapAdminPassword != "" {
created, err := usecase.NewBootstrapAdmin(users, hasher, clock).
Execute(ctx, cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword)
if err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
if created {
log.Info("bootstrap admin created", "email", cfg.BootstrapAdminEmail)
}
}
handler := apihttp.NewServer(log, uc, issuer)
// A blanket per-request deadline: bcrypt is bounded, so anything slower is a
// stuck handler we want to shed rather than hold a connection open.
+18 -9
View File
@@ -32,6 +32,13 @@ type Config struct {
// bcrypt work factor. 0 falls back to the library default (currently 10).
BcryptCost int
// Optional first-admin bootstrap. When both are set and no such account
// exists, the service creates it with role=admin on startup — the only way
// to get the first admin, since /register always makes a plain user and
// promotion needs an existing admin. Idempotent: a no-op once created.
BootstrapAdminEmail string
BootstrapAdminPassword string
// Minimum log level: debug, info, warn, error.
LogLevel string
// Path to a rotated log file. Empty logs to stdout only.
@@ -64,15 +71,17 @@ func LoadConfig() (Config, error) {
}
cfg := Config{
Addr: getEnv("USERSERVICE_ADDR", ":8081"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
TokenTTL: 24 * time.Hour,
DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second,
RequestTimeout: 15 * time.Second,
Addr: getEnv("USERSERVICE_ADDR", ":8081"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
BootstrapAdminEmail: os.Getenv("BOOTSTRAP_ADMIN_EMAIL"),
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
TokenTTL: 24 * time.Hour,
DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second,
RequestTimeout: 15 * time.Second,
}
if cfg.DatabaseURL == "" {
+65
View File
@@ -0,0 +1,65 @@
package usecase
import (
"context"
"errors"
"github.com/emil28092005/SciMesh/users/internal/domain"
)
// BootstrapAdmin seeds the first admin account. It exists because there is no
// other way to create one: /register always makes a plain user, and promoting a
// user to admin requires an already-existing admin. Running it at startup with
// operator-supplied credentials breaks that chicken-and-egg.
type BootstrapAdmin struct {
users UserRepository
hasher PasswordHasher
clk Clock
}
func NewBootstrapAdmin(users UserRepository, hasher PasswordHasher, clk Clock) *BootstrapAdmin {
return &BootstrapAdmin{users: users, hasher: hasher, clk: clk}
}
// Execute creates the admin if it does not already exist, reporting whether it
// created one. It is idempotent: a second run (a restart) finds the account and
// does nothing, so it is safe to call on every boot.
func (uc *BootstrapAdmin) Execute(ctx context.Context, email, password string) (created bool, err error) {
email = domain.NormalizeEmail(email)
if _, err := uc.users.GetByEmail(ctx, email); err == nil {
return false, nil // already bootstrapped
} else if !errors.Is(err, ErrUserNotFound) {
return false, err
}
if len(password) < minPasswordLen {
return false, ErrPasswordTooShort
}
if len(password) > maxPasswordLen {
return false, ErrPasswordTooLong
}
hash, err := uc.hasher.Hash(password)
if err != nil {
return false, err
}
u, err := domain.NewUser(email, hash, uc.clk.Now())
if err != nil {
return false, err
}
// Direct role assignment is safe here: this is a trusted server-side seed,
// not a request. A root admin is also a trusted contributor.
u.Role = domain.RoleAdmin
u.Verified = true
if err := uc.users.Insert(ctx, u); err != nil {
// A concurrent bootstrap (two replicas booting at once) is fine: whoever
// lost the race just observes the account now exists.
if errors.Is(err, ErrEmailExists) {
return false, nil
}
return false, err
}
return true, nil
}
+71
View File
@@ -0,0 +1,71 @@
package usecase_test
import (
"context"
"errors"
"testing"
"time"
"github.com/emil28092005/SciMesh/users/internal/auth"
"github.com/emil28092005/SciMesh/users/internal/domain"
"github.com/emil28092005/SciMesh/users/internal/memstore"
"github.com/emil28092005/SciMesh/users/internal/usecase"
)
func newBootstrap() (*usecase.BootstrapAdmin, *memstore.UserRepo) {
users := memstore.NewUserRepo()
hasher := auth.NewHasher(4)
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
return usecase.NewBootstrapAdmin(users, hasher, clk), users
}
func TestBootstrapCreatesAdmin(t *testing.T) {
bs, users := newBootstrap()
created, err := bs.Execute(context.Background(), "Root@Example.com", "rootpassword")
if err != nil {
t.Fatalf("bootstrap: %v", err)
}
if !created {
t.Fatal("expected an admin to be created")
}
u, err := users.GetByEmail(context.Background(), "root@example.com")
if err != nil {
t.Fatalf("admin not persisted: %v", err)
}
if u.Role != domain.RoleAdmin {
t.Errorf("role = %q, want admin", u.Role)
}
if !u.Verified {
t.Error("bootstrap admin should be verified")
}
}
func TestBootstrapIsIdempotent(t *testing.T) {
bs, users := newBootstrap()
ctx := context.Background()
if _, err := bs.Execute(ctx, "root@example.com", "rootpassword"); err != nil {
t.Fatal(err)
}
created, err := bs.Execute(ctx, "root@example.com", "rootpassword")
if err != nil {
t.Fatalf("second run: %v", err)
}
if created {
t.Error("second run must not create a duplicate admin")
}
// The account must still be a single admin.
if u, _ := users.GetByEmail(ctx, "root@example.com"); u.Role != domain.RoleAdmin {
t.Errorf("role changed: %q", u.Role)
}
}
func TestBootstrapRejectsWeakPassword(t *testing.T) {
bs, _ := newBootstrap()
if _, err := bs.Execute(context.Background(), "root@example.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
t.Errorf("got %v, want ErrPasswordTooShort", err)
}
}