feat(audit): route every LogAudit call through a best-effort WriteAudit helper

Audit writes stay best-effort — a LogAudit failure must never fail or abort
the request — but a failed write must no longer be silently discarded. Add
db.WriteAudit(auditor, actor, action, targetType, targetID, detail), which
logs a failed write with actor/action/target context (never the detail
string, which may be sensitive) and never propagates the error.

The Auditor interface is satisfied structurally by both *db.DB and the
service-layer Store, so api/admin/ws/service all reach the helper without an
import cycle. Converts all ~26 call sites from `_ = LogAudit(...)` (and the
two backup handlers' inline `if err` blocks) to db.WriteAudit. Pinned by
db/audit_test.go: failure logged and not propagated, success logs nothing,
detail never leaks.

Resolves the repo-wide LogAudit policy question flagged by the D8 note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-20 10:48:05 +02:00
co-authored by Claude Fable 5
parent 5de92b9510
commit b60bc8d04b
14 changed files with 139 additions and 35 deletions
+32
View File
@@ -0,0 +1,32 @@
package db
import "log/slog"
// Auditor is the minimal audit-write surface WriteAudit needs. *DB satisfies
// it directly, and the service layer's Store interface does too, so every
// caller — api, admin, ws, service — can route its audit writes through this
// one helper regardless of whether it holds a *DB or a narrower interface.
type Auditor interface {
LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error
}
// WriteAudit records an audit entry best-effort.
//
// Per the D8 policy decision (docs/plans/audit-2026-07-19-decisions.md), audit
// writes stay best-effort: a LogAudit failure must never fail or abort the
// caller's request. But a failed write must never be silently discarded
// either — this helper logs it with the actor/action/target context so the
// gap is visible in the logs. The detail string is intentionally not logged;
// it can carry request-specific or sensitive text and the structured fields
// already identify what was attempted.
func WriteAudit(a Auditor, actorID int64, action, targetType string, targetID int64, detail string) {
if err := a.LogAudit(actorID, action, targetType, targetID, detail); err != nil {
slog.Error("audit log write failed",
"action", action,
"actor_id", actorID,
"target_type", targetType,
"target_id", targetID,
"error", err,
)
}
}
+79
View File
@@ -0,0 +1,79 @@
package db_test
import (
"errors"
"log/slog"
"strings"
"testing"
"github.com/owncord/server/db"
)
// fakeAuditor lets a test drive WriteAudit down either the success or the
// failure path without a real database.
type fakeAuditor struct {
err error
called bool
}
func (f *fakeAuditor) LogAudit(_ int64, _, _ string, _ int64, _ string) error {
f.called = true
return f.err
}
// captureLogs redirects the default slog logger to a buffer for the duration
// of fn and returns everything it wrote.
func captureLogs(t *testing.T, fn func()) string {
t.Helper()
var buf strings.Builder
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(prev)
fn()
return buf.String()
}
func TestWriteAudit_LogsFailureButDoesNotPropagate(t *testing.T) {
a := &fakeAuditor{err: errors.New("disk on fire")}
// WriteAudit returns nothing, so "never propagated" is structural — the
// call simply must not panic and must record the failure.
out := captureLogs(t, func() {
db.WriteAudit(a, 7, "user_ban", "user", 42, "spam")
})
if !a.called {
t.Fatal("WriteAudit did not attempt the underlying LogAudit")
}
for _, want := range []string{
"audit log write failed",
"action=user_ban",
"actor_id=7",
"target_type=user",
"target_id=42",
"disk on fire",
} {
if !strings.Contains(out, want) {
t.Errorf("failure log missing %q; got: %s", want, out)
}
}
// The detail string must not leak into logs.
if strings.Contains(out, "spam") {
t.Errorf("detail string leaked into audit failure log: %s", out)
}
}
func TestWriteAudit_SuccessLogsNothing(t *testing.T) {
a := &fakeAuditor{err: nil}
out := captureLogs(t, func() {
db.WriteAudit(a, 1, "user_login", "user", 1, "")
})
if !a.called {
t.Fatal("WriteAudit did not attempt the underlying LogAudit")
}
if strings.Contains(out, "audit log write failed") {
t.Errorf("successful audit write should not log a failure; got: %s", out)
}
}