// Package audit records meaningful host-facing writes to an // append-only table so the timeline (and any future compliance // export) has a single source of truth. // // Design notes: // // - All writes are best-effort + fire-and-forget. Audit logging must // never block or fail the real action — a host saving branding // should not 500 because the audit insert flaked. We log a warning // and move on. // // - Use the package-level `Record(...)` helper from handler code; it // decorates the call with the request-id middleware adds to the // context and dispatches to the wired Recorder. A nil Recorder // (zero-value Server in tests) is a no-op. // // - Action names follow `entity.verb` (e.g. `branding.update`, // `collaborator.invite`). Verbs are past-tense-implied — the row's // existence is the past tense. // // - Metadata is freeform JSON; keep it small and reviewable. Don't // put secrets in here — this table is queried by support. package audit import ( "context" "encoding/json" "log/slog" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // Recorder writes audit rows asynchronously. Construct one per process // and pass it into the handlers that need it (server.go bundles it on // the few handlers that audit). type Recorder struct { pool *pgxpool.Pool logger *slog.Logger } func New(pool *pgxpool.Pool, logger *slog.Logger) *Recorder { if logger == nil { logger = slog.Default() } return &Recorder{pool: pool, logger: logger} } // Params carries everything the audit_log INSERT needs. Most fields // are nullable so a record can describe an action that doesn't have a // specific target (e.g. "feature_flag.toggle") or doesn't sit under // an event (account-level writes). type Params struct { UserID *uuid.UUID EventID *uuid.UUID Action string // e.g. "branding.update" EntityType string // e.g. "event", "guest", "message" TargetID *uuid.UUID // the row that was acted on (nullable) Metadata map[string]any // free-form context — keep small RequestID string // correlation id from middleware (if any) } // Record inserts one audit_log row. Returns immediately; the insert // runs on a detached goroutine with the package logger. Errors are // warned, never returned. func (r *Recorder) Record(ctx context.Context, p Params) { if r == nil || r.pool == nil { return } // Detach the context so cancelling the inbound HTTP request // doesn't cancel the audit write mid-flight. go r.write(context.WithoutCancel(ctx), p) } func (r *Recorder) write(ctx context.Context, p Params) { var meta []byte if p.Metadata != nil { var err error meta, err = json.Marshal(p.Metadata) if err != nil { r.logger.Warn("audit: marshal metadata", "err", err, "action", p.Action) meta = []byte(`{}`) } } else { meta = []byte(`{}`) } const q = ` INSERT INTO audit_log (user_id, event_id, action, entity_type, target_id, metadata, request_id) VALUES ($1, $2, $3, $4, $5, $6::jsonb, NULLIF($7, '')) ` if _, err := r.pool.Exec(ctx, q, p.UserID, p.EventID, p.Action, nilIfEmpty(p.EntityType), p.TargetID, meta, p.RequestID, ); err != nil { r.logger.Warn("audit: insert failed", "err", err, "action", p.Action, "event_id", p.EventID, "user_id", p.UserID, ) } } func nilIfEmpty(s string) any { if s == "" { return nil } return s }