feat: backend service
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user