diff --git a/users/.env.example b/users/.env.example index 53b8ecf..49803cf 100644 --- a/users/.env.example +++ b/users/.env.example @@ -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 diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go index 6b11926..175da99 100644 --- a/users/cmd/userservice/main.go +++ b/users/cmd/userservice/main.go @@ -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. diff --git a/users/internal/infra/config.go b/users/internal/infra/config.go index 410ee4b..cc8fb74 100644 --- a/users/internal/infra/config.go +++ b/users/internal/infra/config.go @@ -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 == "" { diff --git a/users/internal/usecase/bootstrap.go b/users/internal/usecase/bootstrap.go new file mode 100644 index 0000000..37b602f --- /dev/null +++ b/users/internal/usecase/bootstrap.go @@ -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 +} diff --git a/users/internal/usecase/bootstrap_test.go b/users/internal/usecase/bootstrap_test.go new file mode 100644 index 0000000..a8ce1a2 --- /dev/null +++ b/users/internal/usecase/bootstrap_test.go @@ -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) + } +}