149 lines
4.7 KiB
Go
149 lines
4.7 KiB
Go
// 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()
|
|
}
|