felt/internal/audit/trail.go
Mikkel Georgsen 1978d3d421 docs(01-05): complete Blind Structure + Chip Sets + Templates plan
- SUMMARY.md with self-check passed
- STATE.md updated: plan 6 of 14, 36% progress, 4 decisions added
- ROADMAP.md updated: 5/14 plans complete
- REQUIREMENTS.md: 15 requirements marked complete (BLIND-01-06, CHIP-01-04, FIN-01,02,05,06,10)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:02:11 +01:00

360 lines
11 KiB
Go

// Package audit provides an append-only audit trail and undo engine for the
// Felt tournament engine. Every state-changing action is recorded with previous
// and new state for full reversibility.
package audit
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"log"
"time"
"github.com/felt-app/felt/internal/server/middleware"
)
// Action constants for all auditable mutations.
const (
// Player actions
ActionPlayerBuyin = "player.buyin"
ActionPlayerBust = "player.bust"
ActionPlayerRebuy = "player.rebuy"
ActionPlayerAddon = "player.addon"
ActionPlayerReentry = "player.reentry"
// Financial actions
ActionFinancialBuyin = "financial.buyin"
ActionFinancialRebuy = "financial.rebuy"
ActionFinancialAddon = "financial.addon"
ActionFinancialPayout = "financial.payout"
ActionFinancialChop = "financial.chop"
ActionFinancialBubblePrize = "financial.bubble_prize"
// Clock actions
ActionClockStart = "clock.start"
ActionClockPause = "clock.pause"
ActionClockResume = "clock.resume"
ActionClockAdvance = "clock.advance"
ActionClockRewind = "clock.rewind"
ActionClockJump = "clock.jump"
// Seat actions
ActionSeatAssign = "seat.assign"
ActionSeatMove = "seat.move"
ActionSeatBalance = "seat.balance"
ActionSeatBreakTable = "seat.break_table"
// Tournament actions
ActionTournamentCreate = "tournament.create"
ActionTournamentStart = "tournament.start"
ActionTournamentEnd = "tournament.end"
ActionTournamentCancel = "tournament.cancel"
// Template actions
ActionTemplateCreate = "template.create"
ActionTemplateUpdate = "template.update"
ActionTemplateDelete = "template.delete"
// Operator actions
ActionOperatorLogin = "operator.login"
ActionOperatorLogout = "operator.logout"
ActionOperatorLoginRateLimited = "operator.login_rate_limited"
)
// Publisher defines the interface for publishing audit events to NATS JetStream.
// This avoids a direct dependency on the nats package.
type Publisher interface {
PublishAudit(ctx context.Context, tournamentID string, data []byte) error
}
// natsPublisherAdapter adapts the feltnats.Publisher to the audit.Publisher interface.
// The feltnats.Publisher returns (*jetstream.PubAck, error) but audit only needs error.
type natsPublisherAdapter struct {
publish func(ctx context.Context, tournamentID string, data []byte) error
}
func (a *natsPublisherAdapter) PublishAudit(ctx context.Context, tournamentID string, data []byte) error {
return a.publish(ctx, tournamentID, data)
}
// AuditEntry represents a single audit trail entry.
type AuditEntry struct {
ID string `json:"id"`
TournamentID *string `json:"tournament_id,omitempty"`
Timestamp int64 `json:"timestamp"` // UnixNano
OperatorID string `json:"operator_id"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
PreviousState json.RawMessage `json:"previous_state,omitempty"`
NewState json.RawMessage `json:"new_state,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
UndoneBy *string `json:"undone_by,omitempty"`
}
// Trail provides the append-only audit trail for the Felt tournament engine.
// It records entries to LibSQL and optionally publishes them to NATS JetStream.
type Trail struct {
db *sql.DB
publisher Publisher // optional, nil if no NATS available
}
// NewTrail creates a new audit trail.
func NewTrail(db *sql.DB, publisher Publisher) *Trail {
return &Trail{
db: db,
publisher: publisher,
}
}
// Record persists an audit entry to LibSQL and publishes to NATS JetStream.
// The entry ID and timestamp are generated automatically.
// The operator ID is extracted from the request context if not already set.
func (t *Trail) Record(ctx context.Context, entry AuditEntry) (*AuditEntry, error) {
// Generate ID if not set
if entry.ID == "" {
entry.ID = generateUUID()
}
// Set timestamp to now if not set
if entry.Timestamp == 0 {
entry.Timestamp = time.Now().UnixNano()
}
// Extract operator ID from context if not already set
if entry.OperatorID == "" {
entry.OperatorID = middleware.OperatorIDFromCtx(ctx)
}
if entry.OperatorID == "" {
entry.OperatorID = "system" // fallback for system-generated events
}
// Validate required fields
if entry.Action == "" {
return nil, fmt.Errorf("audit: action is required")
}
// Convert timestamps to seconds for SQLite storage (schema uses epoch seconds)
timestampSeconds := entry.Timestamp / 1_000_000_000
// Insert into database
_, err := t.db.ExecContext(ctx,
`INSERT INTO audit_entries (id, tournament_id, timestamp, operator_id, action, target_type, target_id, previous_state, new_state, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
entry.ID, entry.TournamentID, timestampSeconds, entry.OperatorID,
entry.Action, entry.TargetType, entry.TargetID,
nullableJSON(entry.PreviousState), nullableJSON(entry.NewState), nullableJSON(entry.Metadata),
)
if err != nil {
return nil, fmt.Errorf("audit: insert entry: %w", err)
}
// Publish to NATS JetStream (best-effort, don't fail the operation)
if t.publisher != nil && entry.TournamentID != nil && *entry.TournamentID != "" {
data, err := json.Marshal(entry)
if err != nil {
log.Printf("audit: marshal entry for NATS: %v", err)
} else {
if err := t.publisher.PublishAudit(ctx, *entry.TournamentID, data); err != nil {
log.Printf("audit: publish to NATS: %v", err)
}
}
}
return &entry, nil
}
// GetEntry retrieves a single audit entry by ID.
func (t *Trail) GetEntry(ctx context.Context, entryID string) (*AuditEntry, error) {
entry := &AuditEntry{}
var prevState, newState, metadata sql.NullString
err := t.db.QueryRowContext(ctx,
`SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id,
previous_state, new_state, metadata, undone_by
FROM audit_entries WHERE id = ?`, entryID,
).Scan(
&entry.ID, &entry.TournamentID, &entry.Timestamp, &entry.OperatorID,
&entry.Action, &entry.TargetType, &entry.TargetID,
&prevState, &newState, &metadata, &entry.UndoneBy,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("audit: entry not found: %s", entryID)
}
if err != nil {
return nil, fmt.Errorf("audit: get entry: %w", err)
}
// Convert seconds back to nanoseconds for consistency
entry.Timestamp = entry.Timestamp * 1_000_000_000
if prevState.Valid {
entry.PreviousState = json.RawMessage(prevState.String)
}
if newState.Valid {
entry.NewState = json.RawMessage(newState.String)
}
if metadata.Valid {
entry.Metadata = json.RawMessage(metadata.String)
}
return entry, nil
}
// GetEntries retrieves audit entries with pagination, optionally filtered by tournament ID.
// If tournamentID is empty, all entries are returned.
func (t *Trail) GetEntries(ctx context.Context, tournamentID string, limit, offset int) ([]AuditEntry, error) {
if limit <= 0 {
limit = 50
}
if limit > 1000 {
limit = 1000
}
var rows *sql.Rows
var err error
if tournamentID != "" {
rows, err = t.db.QueryContext(ctx,
`SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id,
previous_state, new_state, metadata, undone_by
FROM audit_entries WHERE tournament_id = ?
ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
tournamentID, limit, offset,
)
} else {
rows, err = t.db.QueryContext(ctx,
`SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id,
previous_state, new_state, metadata, undone_by
FROM audit_entries
ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
limit, offset,
)
}
if err != nil {
return nil, fmt.Errorf("audit: query entries: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var entry AuditEntry
var prevState, newState, metadata sql.NullString
if err := rows.Scan(
&entry.ID, &entry.TournamentID, &entry.Timestamp, &entry.OperatorID,
&entry.Action, &entry.TargetType, &entry.TargetID,
&prevState, &newState, &metadata, &entry.UndoneBy,
); err != nil {
return nil, fmt.Errorf("audit: scan entry: %w", err)
}
// Convert seconds back to nanoseconds
entry.Timestamp = entry.Timestamp * 1_000_000_000
if prevState.Valid {
entry.PreviousState = json.RawMessage(prevState.String)
}
if newState.Valid {
entry.NewState = json.RawMessage(newState.String)
}
if metadata.Valid {
entry.Metadata = json.RawMessage(metadata.String)
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
// GetEntriesByAction retrieves audit entries filtered by action type.
func (t *Trail) GetEntriesByAction(ctx context.Context, action string, limit, offset int) ([]AuditEntry, error) {
if limit <= 0 {
limit = 50
}
rows, err := t.db.QueryContext(ctx,
`SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id,
previous_state, new_state, metadata, undone_by
FROM audit_entries WHERE action = ?
ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
action, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("audit: query entries by action: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var entry AuditEntry
var prevState, newState, metadata sql.NullString
if err := rows.Scan(
&entry.ID, &entry.TournamentID, &entry.Timestamp, &entry.OperatorID,
&entry.Action, &entry.TargetType, &entry.TargetID,
&prevState, &newState, &metadata, &entry.UndoneBy,
); err != nil {
return nil, fmt.Errorf("audit: scan entry: %w", err)
}
entry.Timestamp = entry.Timestamp * 1_000_000_000
if prevState.Valid {
entry.PreviousState = json.RawMessage(prevState.String)
}
if newState.Valid {
entry.NewState = json.RawMessage(newState.String)
}
if metadata.Valid {
entry.Metadata = json.RawMessage(metadata.String)
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
// RecorderFunc returns an AuditRecorder function compatible with the auth package.
// This bridges the auth.AuditRecorder callback type and the audit.Trail.
func (t *Trail) RecorderFunc() func(ctx context.Context, action, targetType, targetID string, metadata map[string]interface{}) error {
return func(ctx context.Context, action, targetType, targetID string, metadata map[string]interface{}) error {
var metadataJSON json.RawMessage
if metadata != nil {
data, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("audit: marshal metadata: %w", err)
}
metadataJSON = data
}
_, err := t.Record(ctx, AuditEntry{
Action: action,
TargetType: targetType,
TargetID: targetID,
Metadata: metadataJSON,
})
return err
}
}
// nullableJSON converts a json.RawMessage to a sql.NullString for storage.
func nullableJSON(data json.RawMessage) sql.NullString {
if len(data) == 0 || string(data) == "null" {
return sql.NullString{}
}
return sql.NullString{String: string(data), Valid: true}
}
// generateUUID generates a v4 UUID.
func generateUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
b[8] = (b[8] & 0x3f) | 0x80 // Variant 1
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}