From 5fb022bbaf29782d43eeb41de9a143c2a8dc3fbb Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 21 Jun 2026 23:45:57 +0100 Subject: [PATCH] feat(store): audit log with retention-based purge --- internal/store/audit.go | 61 ++++++++++++++++++++++++++++++++++++ internal/store/audit_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 internal/store/audit.go create mode 100644 internal/store/audit_test.go diff --git a/internal/store/audit.go b/internal/store/audit.go new file mode 100644 index 0000000..bba65eb --- /dev/null +++ b/internal/store/audit.go @@ -0,0 +1,61 @@ +package store + +import ( + "strconv" + "time" +) + +type AuditEntry struct { + TS string + Account string + Action string + Target string + Result string + Reason string +} + +func (s *Store) Audit(now time.Time, e AuditEntry) error { + var reason any + if e.Reason != "" { + reason = e.Reason + } + _, err := s.db.Exec( + "INSERT INTO audit_log(ts,account,action,target,result,reason) VALUES(?,?,?,?,?,?)", + now.UTC().Format(time.RFC3339), e.Account, e.Action, e.Target, e.Result, reason) + return err +} + +func (s *Store) PurgeAudit(now time.Time) (int64, error) { + v, err := s.GetSetting("audit_retention_days") + if err != nil { // unset => no retention policy + return 0, nil + } + days, err := strconv.Atoi(v) + if err != nil || days <= 0 { + return 0, nil + } + cutoff := now.UTC().AddDate(0, 0, -days).Format(time.RFC3339) + res, err := s.db.Exec("DELETE FROM audit_log WHERE ts < ?", cutoff) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *Store) RecentAudit(limit int) ([]AuditEntry, error) { + rows, err := s.db.Query( + "SELECT ts,account,action,target,result,COALESCE(reason,'') FROM audit_log ORDER BY id DESC LIMIT ?", limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AuditEntry + for rows.Next() { + var e AuditEntry + if err := rows.Scan(&e.TS, &e.Account, &e.Action, &e.Target, &e.Result, &e.Reason); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go new file mode 100644 index 0000000..fb3ee97 --- /dev/null +++ b/internal/store/audit_test.go @@ -0,0 +1,46 @@ +package store + +import ( + "testing" + "time" +) + +func TestAuditInsertAndRecent(t *testing.T) { + s := openTemp(t) + now := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + err := s.Audit(now, AuditEntry{ + Account: "work", Action: "list", Target: "INBOX", Result: "allowed", + }) + if err != nil { + t.Fatalf("Audit: %v", err) + } + got, err := s.RecentAudit(10) + if err != nil || len(got) != 1 { + t.Fatalf("recent: %v len=%d", err, len(got)) + } + if got[0].Action != "list" || got[0].Result != "allowed" { + t.Fatalf("entry wrong: %+v", got[0]) + } +} + +func TestPurgeRespectsRetention(t *testing.T) { + s := openTemp(t) + _ = s.SetSetting("audit_retention_days", "30") + now := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -31) + recent := now.AddDate(0, 0, -5) + _ = s.Audit(old, AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"}) + _ = s.Audit(recent, AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"}) + + n, err := s.PurgeAudit(now) + if err != nil { + t.Fatalf("purge: %v", err) + } + if n != 1 { + t.Fatalf("want 1 purged, got %d", n) + } + got, _ := s.RecentAudit(10) + if len(got) != 1 { + t.Fatalf("want 1 remaining, got %d", len(got)) + } +}