Files
minstrel/internal/audit/audit.go
T
bvandeusenandClaude Opus 5.5 1aba1a41e6
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(tracks): removing a track writes a track_remove audit row (#6294)
RemoveTrack took the admin's id and dropped it, so a merge left an
audit trail and the more destructive removal did not. It now reads the
track's title, artist, album and file path before the delete and writes
them, with whether the album and artist went too, under the new
track_remove action.

The audit action groups lose their plan-era U1/U2/U3 headings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:16:41 -04:00

114 lines
4.5 KiB
Go

// 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"
"log/slog"
"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. Every value is
// declared here, so callers never stringly-type an action and the vocabulary
// has one source.
type Action string
const (
// Accounts and invites.
ActionRegister Action = "register"
ActionPromoteAdmin Action = "promote_admin"
ActionDemoteAdmin Action = "demote_admin"
ActionInviteCreate Action = "invite_create"
ActionInviteRedeem Action = "invite_redeem"
ActionInviteRevoke Action = "invite_revoke"
// Admin user management.
ActionCreateUserAdmin Action = "create_user_admin"
ActionDeleteUser Action = "delete_user"
ActionPasswordResetAdmin Action = "password_reset_admin"
ActionAutoApproveToggle Action = "auto_approve_toggle"
// Credentials.
ActionPasswordChangeSelf Action = "password_change_self"
ActionTokenRegenerate Action = "token_regenerate"
ActionForgotPasswordInit Action = "forgot_password_initiated"
ActionPasswordResetByEmail Action = "password_reset_via_email"
// Revoking sessions is what a user does when they think an account is
// compromised, so it is audited: the trail matters most exactly then.
ActionSessionRevoke Action = "session_revoke"
ActionSessionRevokeOthers Action = "session_revoke_others"
// Duplicate merge. Irreversible: a copy's file and row are removed
// and its history moved onto the copy kept. The metadata names both, so the
// log can answer "where did that file go" long after the report is gone.
ActionDuplicateMerge Action = "duplicate_merge"
// The resolver settling a group without a merge:
// dismissing two versions of a song that are not duplicates, and removing
// copies that filled another song's track slot (their tracks are kept,
// marked missing, and fetched again).
ActionDuplicateDismiss Action = "duplicate_dismiss"
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
// An admin removing a track: its file and row are deleted, with every
// like, play and playlist entry that hung off the row. The metadata names
// the track and its file, so the log can say what went after the row is
// gone.
ActionTrackRemove Action = "track_remove"
// Subsonic password: generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
)
// 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,
})
}
// WriteOrLog writes the audit row; on error, logs at Warn and swallows
// (audit failures must not break user-facing operations — see package doc).
// Use this when the audit is observability, not gating; use Write directly
// when the caller needs strict semantics (e.g. tests).
func WriteOrLog(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, actorID, targetID pgtype.UUID, action Action, metadata map[string]any) {
if err := Write(ctx, pool, actorID, targetID, action, metadata); err != nil {
if logger != nil {
logger.Warn("audit failed", "action", string(action), "err", err)
}
}
}