- 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>
360 lines
11 KiB
Go
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])
|
|
}
|