// Package admin owns the platform's administrator records inside the // `backend.admin_accounts` table together with the Basic Auth verifier // consumed by `backend/internal/server/middleware/basicauth`. // // The package introduces the package on top of the The implementation user surface. // The previous placeholder verifier // (`basicauth.StaticVerifier`) is retired from production wiring; the // admin-account CRUD endpoints under `/api/v1/admin/admin-accounts/*` // flip from 501 placeholders to real implementations backed by // `*admin.Service`. // // The package is intentionally narrow: it owns its own table, exposes // a Verifier-shaped surface, and ships an idempotent env-driven // bootstrap so a fresh deploy can authenticate the first operator // without manual SQL. Cross-domain admin handlers (users, games, // runtime, mail, notification, geo) live in their respective module // packages; this package only owns the credential gate. package admin import ( "context" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5/pgconn" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) // bootstrapBcryptCost is the cost factor used for every admin password // hash. It matches `ARCHITECTURE.md` §14 and `backend/README.md` §12. // // The Stage-5.1 auth code uses `bcrypt.DefaultCost` (10) for one-time // login codes; admin passwords stay separate at cost 12 so the // stronger hashing covers reused secrets. const bootstrapBcryptCost = 12 // pgErrCodeUniqueViolation is the SQLSTATE value emitted by Postgres // when a UNIQUE constraint is violated. The pgx driver surfaces the // value on `*pgconn.PgError`. The constant is duplicated from // `internal/user/user.go` so the two packages stay decoupled. const pgErrCodeUniqueViolation = "23505" // Admin is the read-side aggregate served to handlers and the // in-memory cache. It mirrors the OpenAPI `AdminAccount` schema; the // password hash is intentionally absent so handlers cannot accidentally // surface it. type Admin struct { Username string CreatedAt time.Time LastUsedAt *time.Time DisabledAt *time.Time } // Deps aggregates every collaborator the Service depends on. // Constructing the Service through Deps (rather than positional args) // keeps wiring patches small when new dependencies are added. type Deps struct { // Store must be non-nil. It owns every Postgres query against // `backend.admin_accounts`. Store *Store // Cache must be non-nil. The Verifier consults it on the request // path; mutation methods write through after a successful commit. Cache *Cache // Logger is named under "admin" by NewService. Nil falls back to // zap.NewNop. Logger *zap.Logger // Now overrides time.Now for deterministic tests. A nil Now defaults // to time.Now in NewService. Now func() time.Time } // Service is the admin-domain entry point. Concurrency safety is // delegated to Postgres for persisted state and to the embedded Cache // for the in-memory projection. type Service struct { deps Deps } // NewService constructs a Service from deps. A nil Now defaults to // time.Now; a nil Logger defaults to zap.NewNop. Store and Cache must // be non-nil — calling Service methods with nil values will panic at // first use, matching how main.go signals missing wiring. func NewService(deps Deps) *Service { if deps.Now == nil { deps.Now = time.Now } if deps.Logger == nil { deps.Logger = zap.NewNop() } deps.Logger = deps.Logger.Named("admin") return &Service{deps: deps} } // CreateInput is the parameter struct for Service.Create. type CreateInput struct { Username string Password string } // Validate normalises the request and rejects empty fields. func (in *CreateInput) Validate() error { in.Username = strings.TrimSpace(in.Username) if in.Username == "" { return fmt.Errorf("%w: username must not be empty", ErrInvalidInput) } if in.Password == "" { return fmt.Errorf("%w: password must not be empty", ErrInvalidInput) } return nil } // List returns every admin row ordered by username ASC. func (s *Service) List(ctx context.Context) ([]Admin, error) { rows, _, err := s.deps.Store.ListAll(ctx) if err != nil { return nil, fmt.Errorf("admin list: %w", err) } return rows, nil } // Get returns the admin aggregate for username. Returns ErrNotFound // when no row matches. func (s *Service) Get(ctx context.Context, username string) (Admin, error) { username = strings.TrimSpace(username) if username == "" { return Admin{}, ErrNotFound } admin, _, err := s.deps.Store.Lookup(ctx, username) if err != nil { return Admin{}, err } return admin, nil } // Create persists a fresh admin row with the bcrypt-hashed password, // refreshes the in-memory cache, and returns the persisted aggregate. // Returns ErrUsernameTaken when the username already exists. func (s *Service) Create(ctx context.Context, in CreateInput) (Admin, error) { if err := (&in).Validate(); err != nil { return Admin{}, err } hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bootstrapBcryptCost) if err != nil { return Admin{}, fmt.Errorf("admin create: hash password: %w", err) } admin, err := s.deps.Store.Insert(ctx, in.Username, hash) if err != nil { if errors.Is(err, ErrUsernameTaken) { return Admin{}, err } return Admin{}, fmt.Errorf("admin create: %w", err) } s.deps.Cache.Put(admin, hash) return admin, nil } // Disable sets `disabled_at = now()` when the account is currently // enabled. The operation is idempotent: when the account is already // disabled the existing row is returned unchanged. Returns ErrNotFound // when no row matches. func (s *Service) Disable(ctx context.Context, username string) (Admin, error) { username = strings.TrimSpace(username) if username == "" { return Admin{}, ErrNotFound } now := s.deps.Now().UTC() admin, hash, err := s.deps.Store.SetDisabledAt(ctx, username, &now) if err != nil { return Admin{}, fmt.Errorf("admin disable: %w", err) } s.deps.Cache.Put(admin, hash) return admin, nil } // Enable clears `disabled_at` when the account is currently disabled. // The operation is idempotent: when the account is already enabled the // existing row is returned unchanged. Returns ErrNotFound when no row // matches. func (s *Service) Enable(ctx context.Context, username string) (Admin, error) { username = strings.TrimSpace(username) if username == "" { return Admin{}, ErrNotFound } admin, hash, err := s.deps.Store.SetDisabledAt(ctx, username, nil) if err != nil { return Admin{}, fmt.Errorf("admin enable: %w", err) } s.deps.Cache.Put(admin, hash) return admin, nil } // ResetPassword bcrypt-hashes newPassword and replaces the stored // password_hash. The new password itself is not returned per the // OpenAPI contract ("delivered out-of-band"). func (s *Service) ResetPassword(ctx context.Context, username, newPassword string) (Admin, error) { username = strings.TrimSpace(username) if username == "" { return Admin{}, ErrNotFound } if newPassword == "" { return Admin{}, fmt.Errorf("%w: password must not be empty", ErrInvalidInput) } hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bootstrapBcryptCost) if err != nil { return Admin{}, fmt.Errorf("admin reset password: hash: %w", err) } admin, err := s.deps.Store.UpdatePasswordHash(ctx, username, hash) if err != nil { return Admin{}, fmt.Errorf("admin reset password: %w", err) } s.deps.Cache.Put(admin, hash) return admin, nil } // isUniqueViolation reports whether err is a Postgres UNIQUE // constraint violation. constraintName may be empty to match any // UNIQUE violation. func isUniqueViolation(err error, constraintName string) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } if pgErr.Code != pgErrCodeUniqueViolation { return false } if constraintName == "" { return true } return pgErr.ConstraintName == constraintName }