feat(db/m7-user-mgmt): migration 0022 + audit helper

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 11:51:00 -04:00
parent e48d84cde1
commit 4845628ae4
14 changed files with 693 additions and 8 deletions
+65 -6
View File
@@ -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
}