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