14 plans in 6 waves covering all 68 requirements for the Tournament Engine phase. Includes research (go-libsql, NATS JetStream, Svelte 5 runes, ICM complexity), plan verification (2 iterations), and user feedback (hand-for-hand UX, SEAT-06 reword, re-entry semantics, integration test, DKK defaults, JWT 7-day expiry, clock tap safety). Wave structure: 1: A (scaffold), B (schema) 2: C (auth/audit), D (clock), E (templates), J (frontend scaffold) 3: F (financial), H (seating), M (layout shell) 4: G (player management) 5: I (tournament lifecycle) 6: K (overview/financials), L (players), N (tables/more) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
186 lines
9.5 KiB
Markdown
186 lines
9.5 KiB
Markdown
# 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
|
|
|
|
<task id="C1" title="Implement PIN authentication with JWT issuance">
|
|
**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 <token>` 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
|
|
</task>
|
|
|
|
<task id="C2" title="Implement audit trail and undo engine">
|
|
**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
|
|
</task>
|
|
|
|
## 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)
|