# 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)