feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+236
View File
@@ -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
}
+398
View File
@@ -0,0 +1,398 @@
package admin_test
import (
"context"
"database/sql"
"errors"
"net/url"
"testing"
"time"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/config"
backendpg "galaxy/backend/internal/postgres"
pgshared "galaxy/postgres"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
const (
pgImage = "postgres:16-alpine"
pgUser = "galaxy"
pgPassword = "galaxy"
pgDatabase = "galaxy_backend"
pgSchema = "backend"
pgStartup = 90 * time.Second
pgOpTO = 10 * time.Second
)
// startPostgres spins up a Postgres testcontainer with the backend
// migrations applied. The returned *sql.DB is closed and the container
// terminated by t.Cleanup hooks. Tests skip cleanly when Docker is
// unavailable.
func startPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
pgContainer, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(pgStartup),
),
)
if err != nil {
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
}
t.Cleanup(func() {
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
t.Errorf("terminate postgres container: %v", termErr)
}
})
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("scope dsn: %v", err)
}
cfg := pgshared.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = pgOpTO
db, err := pgshared.OpenPrimary(ctx, cfg)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
t.Fatalf("ping: %v", err)
}
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := parsed.Query()
values.Set("search_path", schema)
if values.Get("sslmode") == "" {
values.Set("sslmode", "disable")
}
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
func buildService(t *testing.T, db *sql.DB) (*admin.Service, *admin.Store, *admin.Cache) {
t.Helper()
store := admin.NewStore(db)
cache := admin.NewCache()
if err := cache.Warm(context.Background(), store); err != nil {
t.Fatalf("warm admin cache: %v", err)
}
svc := admin.NewService(admin.Deps{
Store: store,
Cache: cache,
Logger: zap.NewNop(),
})
return svc, store, cache
}
func TestBootstrapInsertsThenSkips(t *testing.T) {
t.Parallel()
db := startPostgres(t)
store := admin.NewStore(db)
cfg := config.AdminBootstrapConfig{User: "root", Password: "root-secret"}
logger := zap.NewNop()
if err := admin.Bootstrap(context.Background(), store, cfg, logger); err != nil {
t.Fatalf("first bootstrap: %v", err)
}
first, hash, err := store.Lookup(context.Background(), "root")
if err != nil {
t.Fatalf("lookup after first bootstrap: %v", err)
}
if first.Username != "root" {
t.Fatalf("Username = %q, want root", first.Username)
}
if err := bcrypt.CompareHashAndPassword(hash, []byte("root-secret")); err != nil {
t.Fatalf("CompareHashAndPassword: %v", err)
}
// Second call must not modify the row even when the password value
// supplied via env vars differs.
cfg.Password = "different"
if err := admin.Bootstrap(context.Background(), store, cfg, logger); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
_, sameHash, err := store.Lookup(context.Background(), "root")
if err != nil {
t.Fatalf("lookup after second bootstrap: %v", err)
}
if string(hash) != string(sameHash) {
t.Fatalf("password_hash mutated by idempotent bootstrap")
}
}
func TestBootstrapSkipsWhenUserEmpty(t *testing.T) {
t.Parallel()
db := startPostgres(t)
store := admin.NewStore(db)
if err := admin.Bootstrap(context.Background(), store, config.AdminBootstrapConfig{}, zap.NewNop()); err != nil {
t.Fatalf("bootstrap: %v", err)
}
admins, _, err := store.ListAll(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
if len(admins) != 0 {
t.Fatalf("ListAll = %d rows, want 0", len(admins))
}
}
func TestVerifyHappyPath(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
created, err := svc.Create(context.Background(), admin.CreateInput{
Username: "alice",
Password: "alice-secret",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Username != "alice" {
t.Fatalf("Username = %q, want alice", created.Username)
}
ok, err := svc.Verify(context.Background(), "alice", "alice-secret")
if err != nil || !ok {
t.Fatalf("Verify(correct) = (%v, %v), want (true, nil)", ok, err)
}
}
func TestVerifyRejectsWrongPassword(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
_, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"})
if err != nil {
t.Fatalf("create: %v", err)
}
ok, err := svc.Verify(context.Background(), "alice", "bad")
if err != nil {
t.Fatalf("Verify returned error: %v", err)
}
if ok {
t.Fatalf("Verify(wrong) = true, want false")
}
}
func TestVerifyRejectsUnknownUser(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
ok, err := svc.Verify(context.Background(), "ghost", "x")
if err != nil || ok {
t.Fatalf("Verify(ghost) = (%v, %v), want (false, nil)", ok, err)
}
}
func TestVerifyRejectsDisabledAccount(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.Disable(context.Background(), "alice"); err != nil {
t.Fatalf("disable: %v", err)
}
ok, err := svc.Verify(context.Background(), "alice", "good")
if err != nil || ok {
t.Fatalf("Verify(disabled) = (%v, %v), want (false, nil)", ok, err)
}
}
func TestEnableReversesDisable(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.Disable(context.Background(), "alice"); err != nil {
t.Fatalf("disable: %v", err)
}
got, err := svc.Enable(context.Background(), "alice")
if err != nil {
t.Fatalf("enable: %v", err)
}
if got.DisabledAt != nil {
t.Fatalf("DisabledAt = %v, want nil after enable", got.DisabledAt)
}
ok, err := svc.Verify(context.Background(), "alice", "good")
if err != nil || !ok {
t.Fatalf("Verify after enable = (%v, %v), want (true, nil)", ok, err)
}
}
func TestCreateRejectsDuplicateUsername(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "x"}); err != nil {
t.Fatalf("create #1: %v", err)
}
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "y"}); !errors.Is(err, admin.ErrUsernameTaken) {
t.Fatalf("Create #2 err = %v, want ErrUsernameTaken", err)
}
}
func TestCreateRejectsEmptyFields(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "", Password: "x"}); !errors.Is(err, admin.ErrInvalidInput) {
t.Fatalf("Create(empty username) err = %v, want ErrInvalidInput", err)
}
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: ""}); !errors.Is(err, admin.ErrInvalidInput) {
t.Fatalf("Create(empty password) err = %v, want ErrInvalidInput", err)
}
}
func TestResetPasswordReplacesHash(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "old"}); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.ResetPassword(context.Background(), "alice", "new-secret"); err != nil {
t.Fatalf("reset: %v", err)
}
if ok, _ := svc.Verify(context.Background(), "alice", "old"); ok {
t.Fatalf("Verify(old) = true after reset")
}
if ok, err := svc.Verify(context.Background(), "alice", "new-secret"); err != nil || !ok {
t.Fatalf("Verify(new) = (%v, %v), want (true, nil)", ok, err)
}
}
func TestResetPasswordOnUnknownUser(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.ResetPassword(context.Background(), "ghost", "x"); !errors.Is(err, admin.ErrNotFound) {
t.Fatalf("ResetPassword(ghost) err = %v, want ErrNotFound", err)
}
}
func TestListReturnsAllRows(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
for _, u := range []string{"alice", "bob", "carol"} {
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: u, Password: "x"}); err != nil {
t.Fatalf("create %s: %v", u, err)
}
}
got, err := svc.List(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 3 {
t.Fatalf("List = %d rows, want 3", len(got))
}
// Order is by username ASC at the SQL level.
if got[0].Username != "alice" || got[1].Username != "bob" || got[2].Username != "carol" {
t.Fatalf("List order = %v, want [alice bob carol]", []string{got[0].Username, got[1].Username, got[2].Username})
}
}
func TestVerifyTouchesLastUsedAt(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, store, _ := buildService(t, db)
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil {
t.Fatalf("create: %v", err)
}
if ok, err := svc.Verify(context.Background(), "alice", "good"); err != nil || !ok {
t.Fatalf("Verify: (%v, %v)", ok, err)
}
// last_used_at is updated by a fire-and-forget goroutine. Poll until
// it lands or the deadline passes.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
got, _, err := store.Lookup(context.Background(), "alice")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.LastUsedAt != nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("LastUsedAt not populated after Verify")
}
func TestDisableIsIdempotent(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "x"}); err != nil {
t.Fatalf("create: %v", err)
}
first, err := svc.Disable(context.Background(), "alice")
if err != nil {
t.Fatalf("disable #1: %v", err)
}
if first.DisabledAt == nil {
t.Fatalf("DisabledAt = nil after disable")
}
second, err := svc.Disable(context.Background(), "alice")
if err != nil {
t.Fatalf("disable #2: %v", err)
}
if second.DisabledAt == nil {
t.Fatalf("DisabledAt = nil on second disable")
}
}
func TestDisableUnknownUser(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc, _, _ := buildService(t, db)
if _, err := svc.Disable(context.Background(), "ghost"); !errors.Is(err, admin.ErrNotFound) {
t.Fatalf("Disable(ghost) err = %v, want ErrNotFound", err)
}
}
+56
View File
@@ -0,0 +1,56 @@
package admin
import (
"context"
"fmt"
"galaxy/backend/internal/config"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
// Bootstrap inserts the seed admin row when the env-driven
// `BACKEND_ADMIN_BOOTSTRAP_USER` / `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`
// values are supplied and no row with that username exists yet. The
// insert is idempotent across restarts so operators can leave the env
// vars set after the first deploy without re-creating the row on
// every boot.
//
// Bootstrap runs *before* `Cache.Warm` so the warm read picks up the
// seed row. Errors are returned to the caller; the boot path in
// `cmd/backend/main.go` aborts startup if Bootstrap fails (a missing
// admin would lock the surface out anyway, so failing fast is the
// safer default).
//
// When both env vars are empty the function logs "skipped" and
// returns nil. `config.Validate()` already enforces that the username
// and password are set together, so by the time Bootstrap runs the
// remaining "user set without password" combination is impossible.
func Bootstrap(ctx context.Context, store *Store, cfg config.AdminBootstrapConfig, logger *zap.Logger) error {
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("admin.bootstrap")
if cfg.User == "" {
logger.Info("skipped (no env vars)")
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Password), bootstrapBcryptCost)
if err != nil {
return fmt.Errorf("admin bootstrap: hash password: %w", err)
}
inserted, err := store.BootstrapInsert(ctx, cfg.User, hash)
if err != nil {
return fmt.Errorf("admin bootstrap: %w", err)
}
if inserted {
logger.Info("inserted seed admin", zap.String("admin_username", cfg.User))
} else {
logger.Info("skipped (admin exists)", zap.String("admin_username", cfg.User))
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package admin
import (
"context"
"fmt"
"sync"
"sync/atomic"
)
// cacheEntry pairs the admin aggregate with its bcrypt hash. The
// hash is private to the admin package: handlers receive only the
// Admin shape, and Verify consumes the hash directly off the cache.
type cacheEntry struct {
admin Admin
passwordHash []byte
}
// Cache is the in-memory write-through projection of the rows in
// `backend.admin_accounts`. Reads (Get) are RLocked; writes (Put,
// Remove) are Locked.
//
// The cache mirrors the `auth.Cache` and `user.Cache` idioms: callers
// commit to Postgres first, then update the cache. A commit failure
// leaves the cache untouched, matching the previous DB state.
type Cache struct {
mu sync.RWMutex
byName map[string]cacheEntry
ready atomic.Bool
}
// NewCache constructs an empty Cache. The cache reports Ready() ==
// false until Warm completes successfully.
func NewCache() *Cache {
return &Cache{
byName: make(map[string]cacheEntry),
}
}
// Warm replaces the cache contents with every row loaded from store.
// It is intended to be called exactly once at process boot before the
// HTTP listener accepts traffic; successful completion flips Ready to
// true. Subsequent calls re-warm the cache (useful in tests).
func (c *Cache) Warm(ctx context.Context, store *Store) error {
if c == nil {
return nil
}
admins, hashes, err := store.ListAll(ctx)
if err != nil {
return fmt.Errorf("admin cache warm: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
c.byName = make(map[string]cacheEntry, len(admins))
for i, a := range admins {
c.byName[a.Username] = cacheEntry{
admin: a,
passwordHash: hashes[i],
}
}
c.ready.Store(true)
return nil
}
// Ready reports whether Warm has completed at least once. The HTTP
// readiness probe wires through this method together with the auth
// and user caches so `/readyz` only flips to 200 after every cache is
// hydrated.
func (c *Cache) Ready() bool {
if c == nil {
return false
}
return c.ready.Load()
}
// Size returns the number of cached admin accounts. Useful for the
// startup log line and tests.
func (c *Cache) Size() int {
if c == nil {
return 0
}
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.byName)
}
// Get returns the cached entry for username and a presence flag.
// Misses always return the zero entry and false.
func (c *Cache) Get(username string) (Admin, []byte, bool) {
if c == nil {
return Admin{}, nil, false
}
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.byName[username]
if !ok {
return Admin{}, nil, false
}
return entry.admin, entry.passwordHash, true
}
// Put stores admin and its bcrypt hash in the cache. It is safe to
// call on an existing entry — the value is overwritten with the
// latest snapshot. The slice is stored by reference; callers must not
// mutate it after handing it to Put.
func (c *Cache) Put(admin Admin, passwordHash []byte) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.byName[admin.Username] = cacheEntry{
admin: admin,
passwordHash: passwordHash,
}
}
// Remove evicts the entry for username. Calling Remove on a missing
// entry is a no-op. The current implementation ships no Delete operation; the helper
// exists for symmetry with `auth.Cache` / `user.Cache` and for any
// future hard-delete flow.
func (c *Cache) Remove(username string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.byName, username)
}
+98
View File
@@ -0,0 +1,98 @@
package admin_test
import (
"context"
"database/sql"
"testing"
"time"
"galaxy/backend/internal/admin"
_ "github.com/jackc/pgx/v5/stdlib"
)
func TestCacheGetReturnsFalseUntilPut(t *testing.T) {
t.Parallel()
cache := admin.NewCache()
if _, _, ok := cache.Get("alice"); ok {
t.Fatalf("Get on empty cache returned ok=true")
}
}
func TestCacheReadyFlipsAfterWarm(t *testing.T) {
t.Parallel()
cache := admin.NewCache()
if cache.Ready() {
t.Fatalf("Ready() = true before Warm")
}
store := admin.NewStore(stubDB(t))
if err := cache.Warm(context.Background(), store); err == nil {
t.Fatalf("Warm against an empty stub DB unexpectedly succeeded")
}
if cache.Ready() {
t.Fatalf("Ready() flipped after a failed Warm")
}
}
func TestCachePutIsVisibleToReader(t *testing.T) {
t.Parallel()
cache := admin.NewCache()
now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
cache.Put(admin.Admin{
Username: "alice",
CreatedAt: now,
}, []byte("hash-bytes"))
got, hash, ok := cache.Get("alice")
if !ok {
t.Fatalf("Get after Put returned ok=false")
}
if got.Username != "alice" {
t.Fatalf("Get returned username %q, want alice", got.Username)
}
if string(hash) != "hash-bytes" {
t.Fatalf("Get returned hash %q, want hash-bytes", string(hash))
}
if cache.Size() != 1 {
t.Fatalf("Size = %d, want 1", cache.Size())
}
}
func TestCachePutOverwrites(t *testing.T) {
t.Parallel()
cache := admin.NewCache()
cache.Put(admin.Admin{Username: "alice"}, []byte("old"))
cache.Put(admin.Admin{Username: "alice"}, []byte("new"))
_, hash, ok := cache.Get("alice")
if !ok || string(hash) != "new" {
t.Fatalf("Get after overwrite returned ok=%v hash=%q, want ok=true hash=new", ok, string(hash))
}
if cache.Size() != 1 {
t.Fatalf("Size after overwrite = %d, want 1", cache.Size())
}
}
func TestCacheRemove(t *testing.T) {
t.Parallel()
cache := admin.NewCache()
cache.Put(admin.Admin{Username: "alice"}, []byte("hash"))
cache.Remove("alice")
if _, _, ok := cache.Get("alice"); ok {
t.Fatalf("Get after Remove returned ok=true")
}
cache.Remove("alice") // idempotent — must not panic
}
// stubDB returns a *sql.DB that fails every query. Used only by the
// "Warm-on-failure does not flip Ready" test where the actual driver
// behaviour is irrelevant.
func stubDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("pgx", "postgres://disabled.invalid:5432/none?sslmode=disable&connect_timeout=1")
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
+21
View File
@@ -0,0 +1,21 @@
package admin
import "errors"
// Sentinel errors emitted by Service methods. Handlers translate them
// into HTTP responses; callers in tests can match on them with
// errors.Is.
var (
// ErrNotFound is returned when a lookup against `backend.admin_accounts`
// matches no row. Handlers map it to HTTP 404.
ErrNotFound = errors.New("admin: account not found")
// ErrUsernameTaken is returned by Create when the supplied username
// already exists. Handlers map it to HTTP 409 with code "conflict".
ErrUsernameTaken = errors.New("admin: username already in use")
// ErrInvalidInput is returned when a request is syntactically valid
// but semantically rejected (empty username, empty password). Handlers
// map it to HTTP 400.
ErrInvalidInput = errors.New("admin: invalid input")
)
+214
View File
@@ -0,0 +1,214 @@
package admin
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"galaxy/backend/internal/postgres/jet/backend/model"
"galaxy/backend/internal/postgres/jet/backend/table"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
)
// adminAccountsPrimaryKey is the constraint name surfaced on the
// primary-key UNIQUE violation when a duplicate username is inserted.
// Postgres synthesises the constraint name as `<table>_pkey` for
// primary-key constraints, which matches the migration in
// `backend/internal/postgres/migrations/00001_init.sql:199`.
const adminAccountsPrimaryKey = "admin_accounts_pkey"
// Store is the Postgres-backed query surface for the admin package.
// Queries are built through go-jet against the generated table
// bindings under `backend/internal/postgres/jet/backend/table`.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// adminColumnList is the canonical projection used by every read path.
// The slice ordering matches the destination struct fields.
func adminColumnList() postgres.ColumnList {
return postgres.ColumnList{
table.AdminAccounts.Username,
table.AdminAccounts.PasswordHash,
table.AdminAccounts.CreatedAt,
table.AdminAccounts.LastUsedAt,
table.AdminAccounts.DisabledAt,
}
}
// Lookup returns the admin row and its bcrypt hash for username.
// Returns ErrNotFound when no row matches.
func (s *Store) Lookup(ctx context.Context, username string) (Admin, []byte, error) {
stmt := postgres.SELECT(adminColumnList()).
FROM(table.AdminAccounts).
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))).
LIMIT(1)
var row model.AdminAccounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Admin{}, nil, ErrNotFound
}
return Admin{}, nil, fmt.Errorf("admin store: lookup %q: %w", username, err)
}
admin, hash := modelToAdmin(row)
return admin, hash, nil
}
// ListAll returns every admin row paired with its bcrypt hash, ordered
// by username ASC. Used by Cache.Warm and by the List handler (the
// hashes are dropped before the handler sends a response, but Warm
// needs them so Verify can match without a follow-up query).
func (s *Store) ListAll(ctx context.Context) ([]Admin, [][]byte, error) {
stmt := postgres.SELECT(adminColumnList()).
FROM(table.AdminAccounts).
ORDER_BY(table.AdminAccounts.Username.ASC())
var rows []model.AdminAccounts
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, nil, fmt.Errorf("admin store: list: %w", err)
}
admins := make([]Admin, 0, len(rows))
hashes := make([][]byte, 0, len(rows))
for _, row := range rows {
admin, hash := modelToAdmin(row)
admins = append(admins, admin)
hashes = append(hashes, hash)
}
return admins, hashes, nil
}
// Insert persists a fresh admin row. Returns ErrUsernameTaken when the
// primary-key UNIQUE constraint is violated (concurrent or repeat
// Create).
func (s *Store) Insert(ctx context.Context, username string, passwordHash []byte) (Admin, error) {
stmt := table.AdminAccounts.
INSERT(table.AdminAccounts.Username, table.AdminAccounts.PasswordHash).
VALUES(username, passwordHash).
RETURNING(adminColumnList())
var row model.AdminAccounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if isUniqueViolation(err, adminAccountsPrimaryKey) {
return Admin{}, ErrUsernameTaken
}
return Admin{}, fmt.Errorf("admin store: insert %q: %w", username, err)
}
admin, _ := modelToAdmin(row)
return admin, nil
}
// UpdatePasswordHash replaces the stored bcrypt hash for username.
// Returns ErrNotFound when no row matches.
func (s *Store) UpdatePasswordHash(ctx context.Context, username string, passwordHash []byte) (Admin, error) {
stmt := table.AdminAccounts.
UPDATE(table.AdminAccounts.PasswordHash).
SET(passwordHash).
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))).
RETURNING(adminColumnList())
var row model.AdminAccounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Admin{}, ErrNotFound
}
return Admin{}, fmt.Errorf("admin store: update password for %q: %w", username, err)
}
admin, _ := modelToAdmin(row)
return admin, nil
}
// SetDisabledAt patches `disabled_at` for username. Pass `&time` to
// disable, `nil` to re-enable. Returns the refreshed Admin together
// with its bcrypt hash so the cache stays consistent. Returns
// ErrNotFound when no row matches.
func (s *Store) SetDisabledAt(ctx context.Context, username string, disabledAt *time.Time) (Admin, []byte, error) {
var disabledExpr postgres.Expression
if disabledAt != nil {
disabledExpr = postgres.TimestampzT(*disabledAt)
} else {
disabledExpr = postgres.TimestampzExp(postgres.NULL)
}
stmt := table.AdminAccounts.
UPDATE(table.AdminAccounts.DisabledAt).
SET(disabledExpr).
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username))).
RETURNING(adminColumnList())
var row model.AdminAccounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Admin{}, nil, ErrNotFound
}
return Admin{}, nil, fmt.Errorf("admin store: set disabled_at for %q: %w", username, err)
}
admin, hash := modelToAdmin(row)
return admin, hash, nil
}
// TouchLastUsed bumps last_used_at on a successful Verify. The caller
// runs the update fire-and-forget; errors are returned for logging
// but never propagated to the request.
func (s *Store) TouchLastUsed(ctx context.Context, username string, now time.Time) error {
stmt := table.AdminAccounts.
UPDATE(table.AdminAccounts.LastUsedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.AdminAccounts.Username.EQ(postgres.String(username)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("admin store: touch last_used_at for %q: %w", username, err)
}
return nil
}
// BootstrapInsert inserts the seed admin row when no row with the
// supplied username exists. The boolean reports whether the insert
// happened (true) or was skipped because of an existing row (false).
//
// Idempotent across restarts: subsequent calls with the same username
// return false without modifying the password hash. Operators rotate
// the seed admin's password through `ResetPassword`, not by editing
// env vars and restarting.
func (s *Store) BootstrapInsert(ctx context.Context, username string, passwordHash []byte) (bool, error) {
stmt := table.AdminAccounts.
INSERT(table.AdminAccounts.Username, table.AdminAccounts.PasswordHash).
VALUES(username, passwordHash).
ON_CONFLICT(table.AdminAccounts.Username).
DO_NOTHING()
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("admin store: bootstrap insert %q: %w", username, err)
}
affected, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("admin store: bootstrap rows-affected: %w", err)
}
return affected > 0, nil
}
// modelToAdmin projects a generated model row into the public Admin
// struct plus the raw password hash. The conversion centralises the
// pointer-copy of nullable timestamps so each method stays a one-liner.
func modelToAdmin(row model.AdminAccounts) (Admin, []byte) {
admin := Admin{
Username: row.Username,
CreatedAt: row.CreatedAt,
}
if row.LastUsedAt != nil {
t := *row.LastUsedAt
admin.LastUsedAt = &t
}
if row.DisabledAt != nil {
t := *row.DisabledAt
admin.DisabledAt = &t
}
return admin, row.PasswordHash
}
+132
View File
@@ -0,0 +1,132 @@
package admin
import (
"context"
"errors"
"strings"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
// Verify implements `basicauth.Verifier`. The middleware in
// `internal/server/middleware/basicauth/basicauth.go:84` invokes this
// method on every admin request.
//
// Behaviour:
//
// 1. Empty username rejects fast.
// 2. Cache lookup; on miss, fall back to a direct Postgres read and
// populate the cache. Lookup misses return (false, nil) — no
// account-existence leak.
// 3. Disabled accounts (`disabled_at IS NOT NULL`) reject without
// hitting bcrypt.
// 4. `bcrypt.CompareHashAndPassword` runs constant-time on the
// matching path; a mismatch returns (false, nil) so the
// middleware emits 401 with the standard envelope.
// 5. On match a fire-and-forget goroutine bumps `last_used_at` and
// refreshes the cached entry. Errors on the bump are logged but
// never block the request.
// 6. Any other error returned by the lookup path surfaces to the
// middleware which maps it to 500.
func (s *Service) Verify(ctx context.Context, username, password string) (bool, error) {
username = strings.TrimSpace(username)
if username == "" {
return false, nil
}
admin, hash, err := s.lookupForVerify(ctx, username)
if err != nil {
if errors.Is(err, ErrNotFound) {
return false, nil
}
return false, err
}
if admin.DisabledAt != nil {
return false, nil
}
switch err := bcrypt.CompareHashAndPassword(hash, []byte(password)); {
case err == nil:
s.touchLastUsedAsync(username, hash)
return true, nil
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
return false, nil
default:
return false, err
}
}
// lookupForVerify reads the cache first and falls back to Postgres on
// miss, populating the cache so subsequent requests skip the round
// trip. The returned hash slice is the cached entry's reference;
// callers must not mutate it.
func (s *Service) lookupForVerify(ctx context.Context, username string) (Admin, []byte, error) {
if admin, hash, ok := s.deps.Cache.Get(username); ok {
return admin, hash, nil
}
admin, hash, err := s.deps.Store.Lookup(ctx, username)
if err != nil {
return Admin{}, nil, err
}
s.deps.Cache.Put(admin, hash)
return admin, hash, nil
}
// touchLastUsedAsync schedules a fire-and-forget UPDATE on
// `last_used_at`. The update uses a fresh background context so the
// write survives the request lifecycle even when the caller
// disconnects mid-response. On success the cached entry is refreshed
// in place so subsequent reads see the new timestamp; failures are
// logged at warn level and the cache stays at the old value.
//
// `last_used_at` is observability-only: it never gates authentication.
// The fire-and-forget pattern keeps the request path single-digit
// milliseconds even under transient Postgres latency.
func (s *Service) touchLastUsedAsync(username string, hash []byte) {
now := s.deps.Now().UTC()
go func() {
// Background context — the request may complete before the
// goroutine reaches Postgres. The store query carries no
// timeout of its own; the pool's default operation timeout
// applies instead.
ctx := context.Background()
if err := s.deps.Store.TouchLastUsed(ctx, username, now); err != nil {
s.deps.Logger.Warn("touch last_used_at failed",
zap.String("admin_username", username),
zap.Error(err),
)
return
}
// Refresh the cached entry. We re-read so the cache reflects
// any concurrent disable/enable that happened between the
// successful Verify and the bump.
if admin, freshHash, ok := s.deps.Cache.Get(username); ok {
admin.LastUsedAt = &now
// Prefer the slice that was just verified; if the cache
// rotated to a different hash (concurrent password
// reset), keep the cached one to avoid clobbering it.
if hashesEqual(freshHash, hash) {
s.deps.Cache.Put(admin, hash)
} else {
s.deps.Cache.Put(admin, freshHash)
}
}
}()
}
// hashesEqual reports whether two bcrypt hashes are byte-identical.
// The caller cares only about staleness detection — bcrypt hashes are
// not secret in the cache (the cache lives in process memory), so a
// timing-leaking comparison is acceptable.
func hashesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}