first commit
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// eventually polls List until at least want entries are persisted (writes are
|
||||
// async) or it times out.
|
||||
func eventually(t *testing.T, s *Store, want int) []Entry {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
got, err := s.List(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) >= want {
|
||||
return got
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for %d entries, have %d", want, len(got))
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAndList(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.Record("alice", "POST", "/api/users", "users", 200)
|
||||
s.Record("bob", "DELETE", "/api/users/x", "users", 403)
|
||||
|
||||
got := eventually(t, s, 2)
|
||||
// Newest first.
|
||||
if got[0].Username != "bob" || got[0].Status != 403 {
|
||||
t.Errorf("newest entry wrong: %+v", got[0])
|
||||
}
|
||||
if got[1].Username != "alice" || got[1].Method != "POST" {
|
||||
t.Errorf("oldest entry wrong: %+v", got[1])
|
||||
}
|
||||
if got[0].Time == "" {
|
||||
t.Error("time not populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLimit(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
for range 5 {
|
||||
s.Record("u", "POST", "/api/x", "system", 200)
|
||||
}
|
||||
eventually(t, s, 5)
|
||||
got, err := s.List(3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Errorf("limit not honored: got %d, want 3", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseDrains(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
s, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 50 {
|
||||
s.Record("u", "POST", "/api/x", "system", 200)
|
||||
}
|
||||
// Close must flush everything still buffered before returning.
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Reopen and confirm all 50 made it to disk.
|
||||
s2, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s2.List(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 50 {
|
||||
t.Errorf("Close did not drain: got %d entries, want 50", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEmpty(t *testing.T) {
|
||||
got, err := newTestStore(t).List(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("fresh store should be empty, got %d", len(got))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user