package gateway import ( "context" "database/sql" "fmt" "log/slog" "sync" "time" ) // AuditRecord is one persisted admin audit entry (who/what/when/result). type AuditRecord struct { ID int64 `json:"id"` Who string `json:"who"` What string `json:"what"` When string `json:"when"` Result string `json:"result"` } // AuditStore persists the admin audit trail. Implementations must be safe for // concurrent use. *SQLiteAuditStore is the production implementation (wired by the // composition root over a.db); when none is injected the gateway falls back to an // in-memory store so the endpoint always returns data. type AuditStore interface { // Append persists one audit entry at ts. Append(ctx context.Context, who, what, result string, ts time.Time) error // List returns at most limit most-recent entries, newest first. A non-positive // limit selects the default. List(ctx context.Context, limit int) ([]AuditRecord, error) } // memoryAuditStore is the nil-injection fallback: it holds the audit trail in // process memory so the admin endpoint works before the composition root injects a // SQLite store. It is concurrency-safe. type memoryAuditStore struct { mu sync.Mutex records []AuditRecord seq int64 } // newMemoryAuditStore builds an empty in-memory audit store. func newMemoryAuditStore() *memoryAuditStore { return &memoryAuditStore{records: make([]AuditRecord, 0, 64)} } // Append stores one entry with a monotonically increasing id. func (m *memoryAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error { if err := ctx.Err(); err != nil { return err } m.mu.Lock() defer m.mu.Unlock() m.seq++ m.records = append(m.records, AuditRecord{ ID: m.seq, Who: who, What: what, When: ts.UTC().Format(time.RFC3339), Result: result, }) return nil } // List returns the most recent entries, newest first. func (m *memoryAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) { if err := ctx.Err(); err != nil { return nil, err } if limit <= 0 { limit = defaultObsLimit } if limit > maxObsLimit { limit = maxObsLimit } m.mu.Lock() defer m.mu.Unlock() n := len(m.records) start := n - limit if start < 0 { start = 0 } out := make([]AuditRecord, n-start) copy(out, m.records[start:]) for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out, nil } // audit DDL for a self-contained SQLite store (no migration bookkeeping), the // same pattern channel/session and backthink use. recorded_at is the UTC RFC3339 // wall-clock of the audited action. const ( createAuditSQL = ` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, who TEXT NOT NULL, what TEXT NOT NULL, result TEXT NOT NULL, recorded_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) )` createAuditIndexSQL = `CREATE INDEX IF NOT EXISTS idx_audit_logs_recorded_at ON audit_logs(recorded_at)` insertAuditSQL = `INSERT INTO audit_logs (who, what, result, recorded_at) VALUES (?, ?, ?, ?)` selectAuditSQL = `SELECT id, who, what, result, recorded_at FROM audit_logs ORDER BY id DESC LIMIT ?` ) // SQLiteAuditStore persists the admin audit trail in SQLite. It is safe for // concurrent use by virtue of the underlying *sql.DB. type SQLiteAuditStore struct { db *sql.DB log *slog.Logger } // NewSQLiteAuditStore builds a SQLite-backed audit store over db, guaranteeing the // audit_logs table and index exist. It returns an error when db is nil or the DDL // fails. The composition root wires this over the primary *sql.DB (a.db). func NewSQLiteAuditStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*SQLiteAuditStore, error) { if db == nil { return nil, fmt.Errorf("gateway: audit store: nil database") } if _, err := db.ExecContext(ctx, createAuditSQL); err != nil { return nil, fmt.Errorf("gateway: ensure audit_logs: %w", err) } if _, err := db.ExecContext(ctx, createAuditIndexSQL); err != nil { return nil, fmt.Errorf("gateway: ensure audit_logs index: %w", err) } return &SQLiteAuditStore{db: db, log: sourcedLog(log)}, nil } // Append persists one audit entry at ts. func (s *SQLiteAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error { if _, err := s.db.ExecContext(ctx, insertAuditSQL, who, what, result, ts.UTC().Format(time.RFC3339Nano)); err != nil { return fmt.Errorf("gateway: insert audit: %w", err) } return nil } // List returns the most recent entries, newest first. func (s *SQLiteAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) { if limit <= 0 { limit = defaultObsLimit } if limit > maxObsLimit { limit = maxObsLimit } if err := ctx.Err(); err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, selectAuditSQL, limit) if err != nil { return nil, fmt.Errorf("gateway: list audit: %w", err) } defer rows.Close() records := make([]AuditRecord, 0, limit) for rows.Next() { var r AuditRecord if err := rows.Scan(&r.ID, &r.Who, &r.What, &r.Result, &r.When); err != nil { return nil, fmt.Errorf("gateway: scan audit: %w", err) } records = append(records, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("gateway: iterate audit: %w", err) } return records, nil }