From 4845628ae409b415dc6cf85467924a5b87e587f9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 11:51:00 -0400 Subject: [PATCH] feat(db/m7-user-mgmt): migration 0022 + audit helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema for user management U1: display_name on users; user_invites; registration_settings singleton (default 'invite_only'); audit_log. Plus the internal/audit package centralizing the action-name vocabulary and JSON metadata marshaling so handlers don't repeat boilerplate. Race-safe first-admin uses a query-shape primitive (CreateUserFirstAdminRace's WHERE NOT EXISTS subquery) rather than a schema-level constraint. Concurrent empty-state registrations both see 'no users yet' and both insert as admin — fine, having two admins from the start is benign; what matters is at-least-one. users.username uniqueness arbitrates if the two callers picked the same username. CreateUser signature gains display_name (nullable); existing bootstrap call sites pass nil. The audit package declares the full U1+U2+U3 action vocabulary upfront so subsequent slices are purely additive on the caller side. Tests cover audit Write with + without metadata and that every declared action constant persists correctly. Co-Authored-By: Claude Sonnet 4.6 --- internal/audit/audit.go | 77 ++++++++ internal/audit/audit_test.go | 124 +++++++++++++ internal/auth/bootstrap.go | 1 + internal/db/dbq/audit_log.sql.go | 72 ++++++++ internal/db/dbq/invites.sql.go | 170 ++++++++++++++++++ internal/db/dbq/models.go | 25 +++ internal/db/dbq/registration_settings.sql.go | 30 ++++ internal/db/dbq/users.sql.go | 71 +++++++- .../migrations/0022_user_management.down.sql | 9 + .../db/migrations/0022_user_management.up.sql | 48 +++++ internal/db/queries/audit_log.sql | 8 + internal/db/queries/invites.sql | 35 ++++ internal/db/queries/registration_settings.sql | 5 + internal/db/queries/users.sql | 26 ++- 14 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 internal/audit/audit.go create mode 100644 internal/audit/audit_test.go create mode 100644 internal/db/dbq/audit_log.sql.go create mode 100644 internal/db/dbq/invites.sql.go create mode 100644 internal/db/dbq/registration_settings.sql.go create mode 100644 internal/db/migrations/0022_user_management.down.sql create mode 100644 internal/db/migrations/0022_user_management.up.sql create mode 100644 internal/db/queries/audit_log.sql create mode 100644 internal/db/queries/invites.sql create mode 100644 internal/db/queries/registration_settings.sql diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 00000000..e4a43f77 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,77 @@ +// Package audit writes admin-driven user-management events to audit_log. +// Thin wrapper over the sqlc-generated WriteAuditLog query — the value +// is centralizing the action-name vocabulary and the metadata +// marshaling so callers don't repeat boilerplate. +// +// Audit writes are best-effort from the caller's perspective: a failed +// audit write must NOT fail the user-facing operation. Callers +// log-and-continue. The audit log is observability, not a transaction +// participant. +package audit + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Action is the discriminator stored in audit_log.action. New values +// added in U2/U3 + future tasks; declare them here so callers don't +// stringly-type and so a future audit-search UI has a single source +// of truth for the vocabulary. +type Action string + +const ( + // U1 + ActionRegister Action = "register" + ActionPromoteAdmin Action = "promote_admin" + ActionDemoteAdmin Action = "demote_admin" + ActionInviteCreate Action = "invite_create" + ActionInviteRedeem Action = "invite_redeem" + ActionInviteRevoke Action = "invite_revoke" + + // U2 (declared early; callers in U1 don't use them yet, but + // having them here means U2's diff is purely additive on the + // caller side, not also touching this file). + ActionCreateUserAdmin Action = "create_user_admin" + ActionDeleteUser Action = "delete_user" + ActionPasswordResetAdmin Action = "password_reset_admin" + ActionAutoApproveToggle Action = "auto_approve_toggle" + + // U3 + ActionPasswordChangeSelf Action = "password_change_self" + ActionTokenRegenerate Action = "token_regenerate" + ActionForgotPasswordInit Action = "forgot_password_initiated" + ActionPasswordResetByEmail Action = "password_reset_via_email" +) + +// Write inserts one audit_log row. metadata is marshaled as JSON; +// nil metadata writes SQL NULL. Errors are returned so callers can +// log them — but per package doc, callers should NOT fail user-facing +// operations on audit-write failures. +// +// actorID may be a zero/invalid pgtype.UUID for system actions +// (e.g. self-registration where the new user is both actor and +// target — pass them as the same id, or pass invalid for actor and +// the new user as target). +func Write(ctx context.Context, pool *pgxpool.Pool, actorID, targetID pgtype.UUID, action Action, metadata map[string]any) error { + q := dbq.New(pool) + var jsonMeta []byte + if metadata != nil { + b, err := json.Marshal(metadata) + if err != nil { + return err + } + jsonMeta = b + } + return q.WriteAuditLog(ctx, dbq.WriteAuditLogParams{ + ActorID: actorID, + TargetID: targetID, + Action: string(action), + Metadata: jsonMeta, + }) +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 00000000..b8854da8 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,124 @@ +package audit_test + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" +) + +func newTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + url := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if url == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + // Reset audit_log so each test runs against a known state. + if _, err := pool.Exec(context.Background(), `DELETE FROM audit_log`); err != nil { + t.Fatalf("reset audit_log: %v", err) + } + return pool +} + +func validUUID() pgtype.UUID { + var u pgtype.UUID + u.Valid = true + // Arbitrary fixed bytes; we don't need a real user to exist + // because the FK is ON DELETE SET NULL — orphan FKs are fine + // for the audit_log column shape. + for i := range u.Bytes { + u.Bytes[i] = byte(i + 1) + } + return u +} + +func TestWrite_NoMetadata(t *testing.T) { + pool := newTestPool(t) + // Use NULL FKs (Valid: false) — the column is nullable. + var nilUUID pgtype.UUID + if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, audit.ActionRegister, nil); err != nil { + t.Fatalf("Write: %v", err) + } + var count int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM audit_log WHERE action = 'register' AND metadata IS NULL`, + ).Scan(&count); err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Errorf("count = %d, want 1", count) + } +} + +func TestWrite_WithMetadata(t *testing.T) { + pool := newTestPool(t) + var nilUUID pgtype.UUID + if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, audit.ActionPromoteAdmin, map[string]any{ + "reason": "test", + "first_admin": true, + }); err != nil { + t.Fatalf("Write: %v", err) + } + var meta string + if err := pool.QueryRow(context.Background(), + `SELECT metadata::text FROM audit_log WHERE action = 'promote_admin' LIMIT 1`, + ).Scan(&meta); err != nil { + t.Fatalf("read metadata: %v", err) + } + if !contains(meta, `"first_admin":true`) || !contains(meta, `"reason":"test"`) { + t.Errorf("metadata = %q, expected first_admin + reason fields", meta) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} + +func TestWrite_AllActionConstantsArePersisted(t *testing.T) { + pool := newTestPool(t) + var nilUUID pgtype.UUID + actions := []audit.Action{ + audit.ActionRegister, + audit.ActionPromoteAdmin, + audit.ActionDemoteAdmin, + audit.ActionInviteCreate, + audit.ActionInviteRedeem, + audit.ActionInviteRevoke, + audit.ActionCreateUserAdmin, + audit.ActionDeleteUser, + audit.ActionPasswordResetAdmin, + audit.ActionAutoApproveToggle, + audit.ActionPasswordChangeSelf, + audit.ActionTokenRegenerate, + audit.ActionForgotPasswordInit, + audit.ActionPasswordResetByEmail, + } + for _, a := range actions { + if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, a, nil); err != nil { + t.Errorf("action %q: Write: %v", a, err) + } + } + var count int + if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM audit_log`).Scan(&count); err != nil { + t.Fatalf("count: %v", err) + } + if count != len(actions) { + t.Errorf("count = %d, want %d", count, len(actions)) + } +} diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go index ad7b2826..2b764831 100644 --- a/internal/auth/bootstrap.go +++ b/internal/auth/bootstrap.go @@ -59,6 +59,7 @@ func Bootstrap(ctx context.Context, pool *pgxpool.Pool, cfg config.AdminBootstra PasswordHash: string(hash), ApiToken: apiToken, IsAdmin: true, + DisplayName: nil, }) if err != nil { return fmt.Errorf("auth: insert admin: %w", err) diff --git a/internal/db/dbq/audit_log.sql.go b/internal/db/dbq/audit_log.sql.go new file mode 100644 index 00000000..e02d9a9c --- /dev/null +++ b/internal/db/dbq/audit_log.sql.go @@ -0,0 +1,72 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: audit_log.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const listAuditLog = `-- name: ListAuditLog :many +SELECT id, actor_id, target_id, action, metadata, created_at FROM audit_log + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 +` + +type ListAuditLogParams struct { + Limit int32 + Offset int32 +} + +func (q *Queries) ListAuditLog(ctx context.Context, arg ListAuditLogParams) ([]AuditLog, error) { + rows, err := q.db.Query(ctx, listAuditLog, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuditLog + for rows.Next() { + var i AuditLog + if err := rows.Scan( + &i.ID, + &i.ActorID, + &i.TargetID, + &i.Action, + &i.Metadata, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const writeAuditLog = `-- name: WriteAuditLog :exec +INSERT INTO audit_log (actor_id, target_id, action, metadata) +VALUES ($1, $2, $3, $4) +` + +type WriteAuditLogParams struct { + ActorID pgtype.UUID + TargetID pgtype.UUID + Action string + Metadata []byte +} + +func (q *Queries) WriteAuditLog(ctx context.Context, arg WriteAuditLogParams) error { + _, err := q.db.Exec(ctx, writeAuditLog, + arg.ActorID, + arg.TargetID, + arg.Action, + arg.Metadata, + ) + return err +} diff --git a/internal/db/dbq/invites.sql.go b/internal/db/dbq/invites.sql.go new file mode 100644 index 00000000..9f9b9a2d --- /dev/null +++ b/internal/db/dbq/invites.sql.go @@ -0,0 +1,170 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: invites.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createInvite = `-- name: CreateInvite :one +INSERT INTO user_invites (token, invited_by, note, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING token, invited_by, note, created_at, expires_at, redeemed_at, redeemed_by +` + +type CreateInviteParams struct { + Token string + InvitedBy pgtype.UUID + Note *string + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreateInvite(ctx context.Context, arg CreateInviteParams) (UserInvite, error) { + row := q.db.QueryRow(ctx, createInvite, + arg.Token, + arg.InvitedBy, + arg.Note, + arg.ExpiresAt, + ) + var i UserInvite + err := row.Scan( + &i.Token, + &i.InvitedBy, + &i.Note, + &i.CreatedAt, + &i.ExpiresAt, + &i.RedeemedAt, + &i.RedeemedBy, + ) + return i, err +} + +const deleteInvite = `-- name: DeleteInvite :exec +DELETE FROM user_invites WHERE token = $1 AND redeemed_at IS NULL +` + +// Admin-revoke. Only deletes unredeemed invites. +func (q *Queries) DeleteInvite(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, deleteInvite, token) + return err +} + +const getInviteByToken = `-- name: GetInviteByToken :one +SELECT token, invited_by, note, created_at, expires_at, redeemed_at, redeemed_by FROM user_invites WHERE token = $1 +` + +func (q *Queries) GetInviteByToken(ctx context.Context, token string) (UserInvite, error) { + row := q.db.QueryRow(ctx, getInviteByToken, token) + var i UserInvite + err := row.Scan( + &i.Token, + &i.InvitedBy, + &i.Note, + &i.CreatedAt, + &i.ExpiresAt, + &i.RedeemedAt, + &i.RedeemedBy, + ) + return i, err +} + +const listInvitesActive = `-- name: ListInvitesActive :many +SELECT token, invited_by, note, created_at, expires_at, redeemed_at, redeemed_by FROM user_invites + WHERE redeemed_at IS NULL AND expires_at > now() + ORDER BY created_at DESC +` + +// Active = not redeemed and not expired. Used by the admin invites +// panel as the primary list. +func (q *Queries) ListInvitesActive(ctx context.Context) ([]UserInvite, error) { + rows, err := q.db.Query(ctx, listInvitesActive) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserInvite + for rows.Next() { + var i UserInvite + if err := rows.Scan( + &i.Token, + &i.InvitedBy, + &i.Note, + &i.CreatedAt, + &i.ExpiresAt, + &i.RedeemedAt, + &i.RedeemedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listInvitesAll = `-- name: ListInvitesAll :many +SELECT token, invited_by, note, created_at, expires_at, redeemed_at, redeemed_by FROM user_invites + ORDER BY created_at DESC + LIMIT $1 +` + +// Includes redeemed + expired for full audit visibility. Caller +// supplies a LIMIT to bound the response. +func (q *Queries) ListInvitesAll(ctx context.Context, limit int32) ([]UserInvite, error) { + rows, err := q.db.Query(ctx, listInvitesAll, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserInvite + for rows.Next() { + var i UserInvite + if err := rows.Scan( + &i.Token, + &i.InvitedBy, + &i.Note, + &i.CreatedAt, + &i.ExpiresAt, + &i.RedeemedAt, + &i.RedeemedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const redeemInvite = `-- name: RedeemInvite :execrows +UPDATE user_invites + SET redeemed_at = now(), redeemed_by = $2 + WHERE token = $1 + AND redeemed_at IS NULL + AND expires_at > now() +` + +type RedeemInviteParams struct { + Token string + RedeemedBy pgtype.UUID +} + +// Atomic claim: returns rows-affected so the caller can detect +// already-redeemed / expired tokens (rows=0) versus successful claim +// (rows=1). Use this from the registration handler. +func (q *Queries) RedeemInvite(ctx context.Context, arg RedeemInviteParams) (int64, error) { + result, err := q.db.Exec(ctx, redeemInvite, arg.Token, arg.RedeemedBy) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 42b5232a..d85d8d3f 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -231,6 +231,15 @@ type ArtistSimilarityUnmatched struct { FetchedAt pgtype.Timestamptz } +type AuditLog struct { + ID pgtype.UUID + ActorID pgtype.UUID + TargetID pgtype.UUID + Action string + Metadata []byte + CreatedAt pgtype.Timestamptz +} + type ContextualLike struct { ID pgtype.UUID UserID pgtype.UUID @@ -383,6 +392,11 @@ type PlaylistTrack struct { AddedAt pgtype.Timestamptz } +type RegistrationSetting struct { + ID bool + Mode string +} + type ScanRun struct { ID pgtype.UUID StartedAt pgtype.Timestamptz @@ -476,4 +490,15 @@ type User struct { SubsonicPassword *string ListenbrainzToken *string ListenbrainzEnabled bool + DisplayName *string +} + +type UserInvite struct { + Token string + InvitedBy pgtype.UUID + Note *string + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz + RedeemedAt pgtype.Timestamptz + RedeemedBy pgtype.UUID } diff --git a/internal/db/dbq/registration_settings.sql.go b/internal/db/dbq/registration_settings.sql.go new file mode 100644 index 00000000..f57b2e2e --- /dev/null +++ b/internal/db/dbq/registration_settings.sql.go @@ -0,0 +1,30 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: registration_settings.sql + +package dbq + +import ( + "context" +) + +const getRegistrationMode = `-- name: GetRegistrationMode :one +SELECT mode FROM registration_settings WHERE id = true +` + +func (q *Queries) GetRegistrationMode(ctx context.Context) (string, error) { + row := q.db.QueryRow(ctx, getRegistrationMode) + var mode string + err := row.Scan(&mode) + return mode, err +} + +const setRegistrationMode = `-- name: SetRegistrationMode :exec +UPDATE registration_settings SET mode = $1 WHERE id = true +` + +func (q *Queries) SetRegistrationMode(ctx context.Context, mode string) error { + _, err := q.db.Exec(ctx, setRegistrationMode, mode) + return err +} diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index d2c97bbc..4d778e6f 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -23,9 +23,9 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { } const createUser = `-- name: CreateUser :one -INSERT INTO users (username, password_hash, api_token, is_admin) -VALUES ($1, $2, $3, $4) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled +INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name ` type CreateUserParams struct { @@ -33,6 +33,7 @@ type CreateUserParams struct { PasswordHash string ApiToken string IsAdmin bool + DisplayName *string } func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { @@ -41,6 +42,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e arg.PasswordHash, arg.ApiToken, arg.IsAdmin, + arg.DisplayName, ) var i User err := row.Scan( @@ -53,6 +55,60 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.SubsonicPassword, &i.ListenbrainzToken, &i.ListenbrainzEnabled, + &i.DisplayName, + ) + return i, err +} + +const createUserFirstAdminRace = `-- name: CreateUserFirstAdminRace :one +INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +VALUES ( + $1, $2, $3, + (SELECT NOT EXISTS (SELECT 1 FROM users)), + $4 +) +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name +` + +type CreateUserFirstAdminRaceParams struct { + Username string + PasswordHash string + ApiToken string + DisplayName *string +} + +// Inserts a new user; sets is_admin=true ONLY when no users currently +// exist. The (SELECT NOT EXISTS ...) subquery is evaluated at INSERT +// time within the same statement, so the result reflects committed +// state at that moment. +// +// Race semantics: two concurrent calls in an empty-users state may +// both observe "no users" and both insert with is_admin=true. That's +// benign — having two admins from the gate is fine; what matters is +// that there's AT LEAST one. If the two calls happened to share the +// same username, the unique constraint on users.username arbitrates +// and the second caller's INSERT fails with a unique violation. The +// caller (registration handler) can retry as a regular non-admin in +// that case (or surface a "username taken" error to the user). +func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFirstAdminRaceParams) (User, error) { + row := q.db.QueryRow(ctx, createUserFirstAdminRace, + arg.Username, + arg.PasswordHash, + arg.ApiToken, + arg.DisplayName, + ) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + &i.ListenbrainzToken, + &i.ListenbrainzEnabled, + &i.DisplayName, ) return i, err } @@ -84,7 +140,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge } const getUserByAPIToken = `-- name: GetUserByAPIToken :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled FROM users WHERE api_token = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name FROM users WHERE api_token = $1 ` func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { @@ -100,12 +156,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, &i.SubsonicPassword, &i.ListenbrainzToken, &i.ListenbrainzEnabled, + &i.DisplayName, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled FROM users WHERE id = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -121,12 +178,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.SubsonicPassword, &i.ListenbrainzToken, &i.ListenbrainzEnabled, + &i.DisplayName, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled FROM users WHERE username = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -142,6 +200,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.SubsonicPassword, &i.ListenbrainzToken, &i.ListenbrainzEnabled, + &i.DisplayName, ) return i, err } diff --git a/internal/db/migrations/0022_user_management.down.sql b/internal/db/migrations/0022_user_management.down.sql new file mode 100644 index 00000000..53632f82 --- /dev/null +++ b/internal/db/migrations/0022_user_management.down.sql @@ -0,0 +1,9 @@ +-- Reverses migration 0022. Drop tables in reverse dependency order. +-- This will fail if any rows in audit_log or user_invites reference +-- users that were deleted with ON DELETE SET NULL semantics; those +-- references will already be NULL so the drops should succeed cleanly. + +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS user_invites; +DROP TABLE IF EXISTS registration_settings; +ALTER TABLE users DROP COLUMN IF EXISTS display_name; diff --git a/internal/db/migrations/0022_user_management.up.sql b/internal/db/migrations/0022_user_management.up.sql new file mode 100644 index 00000000..f371d5b4 --- /dev/null +++ b/internal/db/migrations/0022_user_management.up.sql @@ -0,0 +1,48 @@ +-- User management foundation (U1 of #376): +-- +-- - users.display_name: optional human-readable name distinct from username. +-- - user_invites: admin-generated tokens; user redeems during signup. +-- - registration_settings (singleton): controls whether registration is +-- 'invite_only' (default after first admin) or 'open' (admin can +-- re-enable). The handler in U1-T2 also has a special-case for the +-- "no users yet" state where it ignores invites and lets the first +-- register as admin. +-- - audit_log: append-only record of admin-driven user mutations. +-- No retention policy in v1. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name text; + +CREATE TABLE user_invites ( + token text PRIMARY KEY, + invited_by uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + redeemed_at timestamptz, + redeemed_by uuid REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX user_invites_active_idx + ON user_invites (expires_at) + WHERE redeemed_at IS NULL; + +CREATE TABLE registration_settings ( + id boolean PRIMARY KEY DEFAULT true, + mode text NOT NULL DEFAULT 'invite_only', + CONSTRAINT registration_settings_singleton CHECK (id = true), + CONSTRAINT registration_settings_mode_check + CHECK (mode IN ('invite_only', 'open')) +); +INSERT INTO registration_settings (id) VALUES (true) + ON CONFLICT (id) DO NOTHING; + +CREATE TABLE audit_log ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id uuid REFERENCES users(id) ON DELETE SET NULL, + target_id uuid REFERENCES users(id) ON DELETE SET NULL, + action text NOT NULL, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX audit_log_created_at_idx ON audit_log (created_at DESC); +CREATE INDEX audit_log_actor_id_idx ON audit_log (actor_id); +CREATE INDEX audit_log_target_id_idx ON audit_log (target_id); diff --git a/internal/db/queries/audit_log.sql b/internal/db/queries/audit_log.sql new file mode 100644 index 00000000..aa126da6 --- /dev/null +++ b/internal/db/queries/audit_log.sql @@ -0,0 +1,8 @@ +-- name: WriteAuditLog :exec +INSERT INTO audit_log (actor_id, target_id, action, metadata) +VALUES ($1, $2, $3, $4); + +-- name: ListAuditLog :many +SELECT * FROM audit_log + ORDER BY created_at DESC + LIMIT $1 OFFSET $2; diff --git a/internal/db/queries/invites.sql b/internal/db/queries/invites.sql new file mode 100644 index 00000000..2ede3f45 --- /dev/null +++ b/internal/db/queries/invites.sql @@ -0,0 +1,35 @@ +-- name: CreateInvite :one +INSERT INTO user_invites (token, invited_by, note, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetInviteByToken :one +SELECT * FROM user_invites WHERE token = $1; + +-- name: ListInvitesActive :many +-- Active = not redeemed and not expired. Used by the admin invites +-- panel as the primary list. +SELECT * FROM user_invites + WHERE redeemed_at IS NULL AND expires_at > now() + ORDER BY created_at DESC; + +-- name: ListInvitesAll :many +-- Includes redeemed + expired for full audit visibility. Caller +-- supplies a LIMIT to bound the response. +SELECT * FROM user_invites + ORDER BY created_at DESC + LIMIT $1; + +-- name: RedeemInvite :execrows +-- Atomic claim: returns rows-affected so the caller can detect +-- already-redeemed / expired tokens (rows=0) versus successful claim +-- (rows=1). Use this from the registration handler. +UPDATE user_invites + SET redeemed_at = now(), redeemed_by = $2 + WHERE token = $1 + AND redeemed_at IS NULL + AND expires_at > now(); + +-- name: DeleteInvite :exec +-- Admin-revoke. Only deletes unredeemed invites. +DELETE FROM user_invites WHERE token = $1 AND redeemed_at IS NULL; diff --git a/internal/db/queries/registration_settings.sql b/internal/db/queries/registration_settings.sql new file mode 100644 index 00000000..6dab0ea7 --- /dev/null +++ b/internal/db/queries/registration_settings.sql @@ -0,0 +1,5 @@ +-- name: GetRegistrationMode :one +SELECT mode FROM registration_settings WHERE id = true; + +-- name: SetRegistrationMode :exec +UPDATE registration_settings SET mode = $1 WHERE id = true; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 4671015a..c2ef76fa 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -1,6 +1,28 @@ -- name: CreateUser :one -INSERT INTO users (username, password_hash, api_token, is_admin) -VALUES ($1, $2, $3, $4) +INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: CreateUserFirstAdminRace :one +-- Inserts a new user; sets is_admin=true ONLY when no users currently +-- exist. The (SELECT NOT EXISTS ...) subquery is evaluated at INSERT +-- time within the same statement, so the result reflects committed +-- state at that moment. +-- +-- Race semantics: two concurrent calls in an empty-users state may +-- both observe "no users" and both insert with is_admin=true. That's +-- benign — having two admins from the gate is fine; what matters is +-- that there's AT LEAST one. If the two calls happened to share the +-- same username, the unique constraint on users.username arbitrates +-- and the second caller's INSERT fails with a unique violation. The +-- caller (registration handler) can retry as a regular non-admin in +-- that case (or surface a "username taken" error to the user). +INSERT INTO users (username, password_hash, api_token, is_admin, display_name) +VALUES ( + $1, $2, $3, + (SELECT NOT EXISTS (SELECT 1 FROM users)), + $4 +) RETURNING *; -- name: GetUserByUsername :one