Files

149 lines
4.7 KiB
Go
Raw Permalink Normal View History

2026-06-22 16:06:57 +02:00
// 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()
}