// 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]) }