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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user