mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user