# Plan C: Authentication + Audit Trail + Undo Engine --- wave: 2 depends_on: [01-PLAN-A, 01-PLAN-B] files_modified: - internal/auth/pin.go - internal/auth/jwt.go - internal/server/middleware/auth.go - internal/server/middleware/role.go - internal/audit/trail.go - internal/audit/undo.go - internal/server/routes/auth.go - internal/auth/pin_test.go - internal/audit/trail_test.go - internal/audit/undo_test.go autonomous: true requirements: [AUTH-01, AUTH-03, ARCH-08, PLYR-06] --- ## Goal Operators authenticate with a PIN that produces a local JWT with role claims. Every state-changing action writes an immutable audit trail entry to LibSQL and publishes to NATS JetStream. Any financial action or bust-out can be undone with full state reversal and re-ranking. This is the security and accountability foundation for the entire system. ## Context - **PIN login flow:** Operator enters PIN → bcrypt compare against all operators → JWT issued with role claim (admin/floor/viewer) - **Rate limiting:** Exponential backoff after 5 failures, lockout after 10 (AUTH-05 spec, applied here to operator PIN too) - **JWT:** HS256 signed, 7-day expiry (local system, not internet-exposed — prevents mid-tournament logouts during 12+ hour sessions), claims: sub (operator ID), role, iat, exp - **Audit trail:** Every mutation writes to `audit_entries` table AND publishes to NATS `tournament.{id}.audit` - **Undo:** Creates a NEW audit entry that reverses the original — never deletes/modifies existing entries - See 01-RESEARCH.md Pattern 4 (Event-Sourced Audit Trail), PIN Authentication Flow ## User Decisions (from CONTEXT.md) - **Undo is critical** — bust-outs, rebuys, add-ons, buy-ins can all be undone with full re-ranking - **Late registration soft lock with admin override** — logged in audit trail - **Operator is the Tournament Director (TD)** - **Roles:** Admin (full control), Floor (runtime actions), Viewer (read-only) ## Tasks **1. PIN Service** (`internal/auth/pin.go`): - `AuthService` struct with db, signing key, rate limiter - `Login(ctx, pin string) (token string, operator Operator, err error)`: - Check rate limiter — return `ErrTooManyAttempts` if blocked - Load all operators from DB - Try bcrypt.CompareHashAndPassword against each operator's pin_hash - On match: reset failure counter, issue JWT, return token + operator - On no match: record failure, return `ErrInvalidPIN` - Rate limiter (in-memory, per-IP or global for simplicity): - Track consecutive failures - After 5 failures: 30-second delay - After 8 failures: 5-minute delay - After 10 failures: 30-minute lockout - Reset on successful login - `HashPIN(pin string) (string, error)` — bcrypt with cost 12 (for seed data and operator management) - `CreateOperator(ctx, name, pin, role string) error` — insert operator with hashed PIN - `ListOperators(ctx) ([]Operator, error)` — for admin management - `UpdateOperator(ctx, id, name, pin, role string) error` **2. JWT Service** (`internal/auth/jwt.go`): - `NewToken(operatorID, role string) (string, error)` — creates HS256-signed JWT with claims: sub, role, iat, exp (24h) - `ValidateToken(tokenString string) (*Claims, error)` — parses and validates JWT, returns claims - Claims struct: `OperatorID string`, `Role string` (admin/floor/viewer) - Signing key: generated randomly on first startup, stored in LibSQL `_config` table (persisted across restarts) **3. Auth Middleware** (`internal/server/middleware/auth.go`): - Extract JWT from `Authorization: Bearer ` header - Validate token, extract claims - Store claims in request context (`context.WithValue`) - Return 401 if missing/invalid/expired - Public routes (health, static files, WebSocket initial connect) bypass auth **4. Role Middleware** (`internal/server/middleware/role.go`): - `RequireRole(roles ...string)` middleware factory - Extract claims from context, check if role is in allowed list - Return 403 if insufficient role - Role hierarchy: admin > floor > viewer (admin can do everything floor can do) **5. Auth Routes** (`internal/server/routes/auth.go`): - `POST /api/v1/auth/login` — body: `{"pin": "1234"}` → response: `{"token": "...", "operator": {...}}` - `GET /api/v1/auth/me` — returns current operator from JWT claims - `POST /api/v1/auth/logout` — client-side only (JWT is stateless), but endpoint exists for audit logging **6. Tests** (`internal/auth/pin_test.go`): - Test successful login returns valid JWT - Test wrong PIN returns ErrInvalidPIN - Test rate limiting kicks in after 5 failures - Test lockout after 10 failures - Test successful login resets failure counter - Test JWT validation with expired token returns error - Test role middleware blocks insufficient roles **Verification:** - `POST /api/v1/auth/login` with PIN "1234" returns a JWT token - The token can be used to access protected endpoints - Wrong PIN returns 401 - Rate limiting activates after 5 rapid failures - Role middleware blocks floor from admin-only endpoints **1. Audit Trail** (`internal/audit/trail.go`): - `AuditTrail` struct with db, nats publisher - `Record(ctx, entry AuditEntry) error`: - Generate UUID for entry ID - Set timestamp to `time.Now().UnixNano()` - Extract operator ID from context (set by auth middleware) - Insert into `audit_entries` table in LibSQL - Publish to NATS JetStream subject `tournament.{tournament_id}.audit` - If tournament_id is empty (venue-level action), publish to `venue.audit` - `AuditEntry` struct per 01-RESEARCH.md Pattern 4: - ID, TournamentID, Timestamp, OperatorID, Action, TargetType, TargetID - PreviousState (json.RawMessage), NewState (json.RawMessage) - Metadata (json.RawMessage, optional) - UndoneBy (*string, nullable) - `GetEntries(ctx, tournamentID string, limit, offset int) ([]AuditEntry, error)` — paginated audit log - `GetEntry(ctx, entryID string) (*AuditEntry, error)` — single entry lookup - Action constants: define all action strings as constants: - `player.buyin`, `player.bust`, `player.rebuy`, `player.addon`, `player.reentry` - `financial.buyin`, `financial.rebuy`, `financial.addon`, `financial.payout`, `financial.chop`, `financial.bubble_prize` - `clock.start`, `clock.pause`, `clock.resume`, `clock.advance`, `clock.rewind`, `clock.jump` - `seat.assign`, `seat.move`, `seat.balance`, `seat.break_table` - `tournament.create`, `tournament.start`, `tournament.end`, `tournament.cancel` - `template.create`, `template.update`, `template.delete` - `operator.login`, `operator.logout` - `undo.*` — mirrors each action type **2. Undo Engine** (`internal/audit/undo.go`): - `UndoEngine` struct with db, audit trail, nats publisher - `Undo(ctx, auditEntryID string) error`: - Load the original audit entry - Verify it hasn't already been undone (check `undone_by` field) - Create a NEW audit entry with: - Action: `undo.{original_action}` (e.g., `undo.player.bust`) - PreviousState: original's NewState - NewState: original's PreviousState - Metadata: `{"undone_entry_id": "original-id"}` - Update the original entry's `undone_by` field to point to the new entry (this is the ONE exception to append-only — it marks an entry as undone, not deleting it) - Return the undo entry for the caller to perform the actual state reversal - `CanUndo(ctx, auditEntryID string) (bool, error)` — checks if entry is undoable (not already undone, action type is undoable) - Undoable actions list: `player.bust`, `player.buyin`, `player.rebuy`, `player.addon`, `player.reentry`, `financial.*`, `seat.move`, `seat.balance` - Non-undoable actions: `clock.*`, `tournament.start`, `tournament.end`, `operator.*` **3. Integration with existing infrastructure:** - Add `AuditTrail` to the server struct, pass to all route handlers - Audit trail is a cross-cutting concern — every route handler that mutates state calls `audit.Record()` - Wire up NATS publisher to audit trail **4. Tests** (`internal/audit/trail_test.go`, `internal/audit/undo_test.go`): - Test audit entry is persisted to LibSQL with all fields - Test audit entry is published to NATS JetStream - Test undo creates new entry and marks original as undone - Test double-undo returns error - Test non-undoable action returns error - Test GetEntries pagination works correctly - Test operator ID is extracted from context **Verification:** - Every state mutation creates an audit_entries row with correct previous/new state - NATS JetStream receives the audit event on the correct subject - Undo creates a reversal entry and marks the original - Double-undo is rejected - Audit log is queryable by tournament, action type, and time range ## Verification Criteria 1. PIN login produces valid JWT with role claims 2. Auth middleware rejects requests without valid JWT 3. Role middleware enforces admin/floor/viewer hierarchy 4. Rate limiting activates after 5 failed login attempts 5. Every state mutation produces an audit_entries row 6. Undo creates a new audit entry (never deletes the original) 7. Double-undo is rejected with clear error 8. All tests pass ## Must-Haves (Goal-Backward) - [ ] Operator PIN → local JWT works offline (no external IdP dependency) - [ ] Three roles (Admin, Floor, Viewer) enforced on all API endpoints - [ ] Every state-changing action writes an append-only audit trail entry - [ ] Financial transactions and bust-outs can be undone with audit trail reversal - [ ] Undo never deletes or overwrites existing audit entries (only marks them and creates new ones)