// Package audit records privileged write operations to an embedded SQLite // database so there is a durable "who did what" trail. package auditlog import ( "database/sql" "fmt" "log" "os" "path/filepath" "sync" "time" _ "modernc.org/sqlite" ) // buffer sizes the channel that decouples Record (request path) from the DB // writer goroutine. Bursts up to this many entries return immediately; only a // sustained overflow falls back to a synchronous write. const buffer = 1024 // Entry is one recorded action. type Entry struct { Time string `json:"time" example:"2026-06-20T08:15:04Z" doc:"When the action occurred (RFC3339, UTC)"` Username string `json:"username" example:"alice" doc:"Who performed it"` Method string `json:"method" example:"POST" doc:"HTTP method"` Path string `json:"path" example:"/api/users" doc:"Request path"` Module string `json:"module" example:"users" doc:"Target module"` Status int `json:"status" example:"200" doc:"HTTP response status"` // ts is the capture time (unix seconds), carried through the writer channel // so the stored timestamp reflects when the action happened, not when the // background writer got to it. ts int64 } type Store struct { db *sql.DB ch chan Entry done chan struct{} // closed when the writer has drained and exited // mu guards the channel against a Record racing Close. Record takes RLock // (many concurrent senders); Close takes the write lock to flip closed and // close the channel exactly once. Without it, a Record after Close would // panic sending on a closed channel. mu sync.RWMutex closed bool } // New opens (creating if needed) the audit database at path and starts the // background writer that drains recorded entries to disk. func New(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return nil, fmt.Errorf("audit db dir: %w", err) } db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open audit db: %w", err) } // ponytail: single connection serializes writes, same rationale as the // session store; the background writer is the only writer. db.SetMaxOpenConns(1) if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, username TEXT NOT NULL, method TEXT NOT NULL, path TEXT NOT NULL, module TEXT NOT NULL, status INTEGER NOT NULL )`); err != nil { return nil, fmt.Errorf("create audit table: %w", err) } s := &Store{db: db, ch: make(chan Entry, buffer), done: make(chan struct{})} go s.writer() return s, nil } // writer is the single goroutine that persists entries, keeping all DB writes // off the request path. It exits once Close closes the channel, after draining // whatever is buffered. func (s *Store) writer() { defer close(s.done) for e := range s.ch { s.insert(e) } } // Close stops accepting entries, drains those still buffered, and closes the // database. Call it during shutdown, after the HTTP server has stopped so no // further Record calls can race with the channel close. func (s *Store) Close() error { s.mu.Lock() s.closed = true close(s.ch) s.mu.Unlock() <-s.done return s.db.Close() } func (s *Store) insert(e Entry) { if _, err := s.db.Exec( `INSERT INTO audit (ts, username, method, path, module, status) VALUES (?, ?, ?, ?, ?, ?)`, e.ts, e.Username, e.Method, e.Path, e.Module, e.Status, ); err != nil { log.Printf("audit: insert failed (%s %s by %s): %v", e.Method, e.Path, e.Username, err) } } // Record queues an entry for the background writer and returns immediately. If // the buffer is full (sustained overload), it writes synchronously instead so an // audit entry is never silently dropped - only then does it pay DB latency. func (s *Store) Record(username, method, path, module string, status int) { e := Entry{ts: time.Now().Unix(), Username: username, Method: method, Path: path, Module: module, Status: status} s.mu.RLock() defer s.mu.RUnlock() if s.closed { s.insert(e) // post-shutdown straggler: write directly, channel is gone return } select { case s.ch <- e: default: s.insert(e) } } // List returns the most recent entries, newest first, capped at limit. func (s *Store) List(limit int) ([]Entry, error) { rows, err := s.db.Query( `SELECT ts, username, method, path, module, status FROM audit ORDER BY id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() entries := []Entry{} for rows.Next() { var e Entry var ts int64 if err := rows.Scan(&ts, &e.Username, &e.Method, &e.Path, &e.Module, &e.Status); err != nil { return nil, err } e.Time = time.Unix(ts, 0).UTC().Format(time.RFC3339) entries = append(entries, e) } return entries, rows.Err() }