215 lines
7.4 KiB
Go
215 lines
7.4 KiB
Go
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
|
|
}
|