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