refactor(server): audit.WriteOrLog wrapper; migrate 13 sites (T5)

Add audit.WriteOrLog: a one-line wrapper around Write that logs at
Warn and swallows the error, matching the package contract that
audit failures must not break user-facing operations.

Migrate the 13 call sites across 7 files in internal/api/ from the
3-line "if err != nil { logger.Warn(...) }" shape to a single call.
audit.Write stays exported for tests + any future caller that
needs strict semantics.

Adds three tests: success (no log), failure-via-closed-pool (Warn
record with action+err keys), and nil-logger (no panic). Tests
skip when MINSTREL_TEST_DATABASE_URL is unset, matching the
existing harness convention.
This commit is contained in:
2026-05-07 21:33:14 -04:00
parent 49218e5231
commit 965df28127
9 changed files with 103 additions and 46 deletions
+4 -8
View File
@@ -93,10 +93,8 @@ func (h *handlers) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
return
}
if err := audit.Write(r.Context(), h.pool, user.ID, pgtype.UUID{}, audit.ActionInviteCreate,
map[string]any{"token": token}); err != nil {
h.logger.Warn("admin: invite-create audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, pgtype.UUID{}, audit.ActionInviteCreate,
map[string]any{"token": token})
writeJSON(w, http.StatusOK, inviteFromRow(row))
}
@@ -127,10 +125,8 @@ func (h *handlers) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
writeErrWithLog(w, h.logger, "admin: delete invite failed", apierror.Internal(err))
return
}
if err := audit.Write(r.Context(), h.pool, user.ID, pgtype.UUID{}, audit.ActionInviteRevoke,
map[string]any{"token": token}); err != nil {
h.logger.Warn("admin: invite-revoke audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, pgtype.UUID{}, audit.ActionInviteRevoke,
map[string]any{"token": token})
w.WriteHeader(http.StatusNoContent)
}
+8 -18
View File
@@ -117,9 +117,7 @@ func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request)
if !req.IsAdmin {
action = audit.ActionDemoteAdmin
}
if err := audit.Write(r.Context(), h.pool, caller.ID, targetID, action, nil); err != nil {
h.logger.Warn("admin: promote/demote audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, action, nil)
writeJSON(w, http.StatusOK, adminUserView{
ID: uuidToString(updated.ID),
@@ -189,10 +187,8 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
return
}
if err := audit.Write(r.Context(), h.pool, caller.ID, user.ID, audit.ActionCreateUserAdmin,
map[string]any{"is_admin": req.IsAdmin}); err != nil {
h.logger.Warn("admin create user: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, user.ID, audit.ActionCreateUserAdmin,
map[string]any{"is_admin": req.IsAdmin})
writeJSON(w, http.StatusOK, adminUserView{
ID: uuidToString(user.ID),
@@ -239,10 +235,8 @@ func (h *handlers) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request)
return
}
if err := audit.Write(r.Context(), h.pool, caller.ID, target.ID, audit.ActionDeleteUser,
map[string]any{"username": target.Username, "was_admin": target.IsAdmin}); err != nil {
h.logger.Warn("admin delete user: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, target.ID, audit.ActionDeleteUser,
map[string]any{"username": target.Username, "was_admin": target.IsAdmin})
w.WriteHeader(http.StatusNoContent)
}
@@ -298,9 +292,7 @@ func (h *handlers) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
return
}
if err := audit.Write(r.Context(), h.pool, caller.ID, targetID, audit.ActionPasswordResetAdmin, nil); err != nil {
h.logger.Warn("admin reset password: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, audit.ActionPasswordResetAdmin, nil)
w.WriteHeader(http.StatusNoContent)
}
@@ -342,10 +334,8 @@ func (h *handlers) handleAdminAutoApproveToggle(w http.ResponseWriter, r *http.R
return
}
if err := audit.Write(r.Context(), h.pool, caller.ID, targetID, audit.ActionAutoApproveToggle,
map[string]any{"auto_approve": req.AutoApprove}); err != nil {
h.logger.Warn("admin auto-approve: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, audit.ActionAutoApproveToggle,
map[string]any{"auto_approve": req.AutoApprove})
writeJSON(w, http.StatusOK, adminUserView{
ID: uuidToString(updated.ID),
+2 -4
View File
@@ -66,10 +66,8 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request)
}
// Audit (best-effort).
if err := audit.Write(r.Context(), h.pool, pgtype.UUID{}, auditTarget, audit.ActionForgotPasswordInit,
map[string]any{"email_match": matched}); err != nil {
h.logger.Warn("forgot-password: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, pgtype.UUID{}, auditTarget, audit.ActionForgotPasswordInit,
map[string]any{"email_match": matched})
writeJSON(w, http.StatusOK, struct{}{})
}
+3 -7
View File
@@ -196,14 +196,10 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
if user.IsAdmin {
auditMeta["first_admin"] = true
}
if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionRegister, auditMeta); err != nil {
h.logger.Warn("register: audit write failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionRegister, auditMeta)
if usedInviteToken != "" {
if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionInviteRedeem,
map[string]any{"token": usedInviteToken}); err != nil {
h.logger.Warn("register: audit invite-redeem write failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionInviteRedeem,
map[string]any{"token": usedInviteToken})
}
writeJSON(w, http.StatusOK, LoginResponse{
+1 -3
View File
@@ -92,9 +92,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
return
}
if err := audit.Write(r.Context(), h.pool, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil); err != nil {
h.logger.Warn("reset password: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil)
w.WriteHeader(http.StatusNoContent)
}
+1 -3
View File
@@ -63,9 +63,7 @@ func (h *handlers) handleChangePassword(w http.ResponseWriter, r *http.Request)
return
}
if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil); err != nil {
h.logger.Warn("change password: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil)
w.WriteHeader(http.StatusNoContent)
}
+1 -3
View File
@@ -54,9 +54,7 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
return
}
if err := audit.Write(r.Context(), h.pool, user.ID, user.ID, audit.ActionTokenRegenerate, nil); err != nil {
h.logger.Warn("regenerate api token: audit failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionTokenRegenerate, nil)
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken})
}