Multi-user Authentication & GDPR Architecture
Status: Draft Owner: software-architect Last updated: 2026-04-21 Parent epic: #78 — Exploration Platform for Individual Traders Related ADRs: 0001, 0002, 0003
1. Context
TradeMasterAPI is moving from a single-operator developer tool to a multi-user platform where individual retail traders log in, hold paper portfolios, and eventually route real orders. That step introduces identity, session, and data-protection surface area the current codebase does not have. This document specifies the authentication and GDPR-compliance architecture the backend (Raptor, i.e. backend_v2/) and frontend (Antlers, i.e. frontend/trademaster_ui/) must implement.
The design is deliberately narrow: it decides boundaries and invariants, not library versions.
2. Invariants (non-negotiable)
- No stored credentials, ever — not hashed, not encrypted. The backend must be incapable of replaying a user-secret. WebAuthn public keys + credential IDs are not credentials in this sense (they cannot be used to impersonate the user without the matching private key held in the authenticator hardware).
- Passkeys / WebAuthn only. No password, no SMS OTP, no email OTP as the auth factor. Platform authenticators (Face ID, Touch ID, Windows Hello) and roaming authenticators (YubiKey, Titan) only.
- Email is the single contact channel, and only after a verification round-trip. No phone, no SMS, no push.
- GDPR by default — DSAR (access/portability/erasure/rectification), documented retention, automated breach-notification pipeline, DPA-ready audit logging.
- No U/P path exists in the codebase. No hidden admin backdoor, no password fallback, no "temporary" bypass. Enforced by schema constraint + code-review checklist + CI grep.
If any piece of the design below reads as violating an invariant, treat it as a bug in the doc.
3. Data model
New SQLite tables in backend_v2/db/ (migration lives in backend_v2/db/migrations/).
users
id TEXT PK (uuid v4)
email TEXT UNIQUE NOT NULL
email_verified_at TIMESTAMP NULL
display_name TEXT NULL
role TEXT NOT NULL DEFAULT 'user' -- 'user' | 'admin'
created_at TIMESTAMP NOT NULL
deleted_at TIMESTAMP NULL -- soft-delete for DSR erasure audit
CHECK (role IN ('user','admin'))
webauthn_credentials
id TEXT PK (credential_id, base64url)
user_id TEXT FK -> users.id ON DELETE CASCADE
public_key BLOB NOT NULL -- COSE public key, NOT a secret
sign_count INTEGER NOT NULL DEFAULT 0
transports TEXT NULL -- csv: 'usb,nfc,internal'
aaguid TEXT NULL
device_label TEXT NULL -- user-supplied: "MacBook", "YubiKey blue"
created_at TIMESTAMP NOT NULL
last_used_at TIMESTAMP NULL
backup_eligible BOOLEAN NOT NULL DEFAULT 0
backup_state BOOLEAN NOT NULL DEFAULT 0
-- Explicit schema-level ban on secrets:
CHECK (length(public_key) > 0)
email_verifications
id TEXT PK
user_id TEXT FK -> users.id ON DELETE CASCADE
email TEXT NOT NULL -- the email being verified (may differ during rectification)
token_hash TEXT NOT NULL -- SHA-256 of single-use link token; the token itself is never stored
expires_at TIMESTAMP NOT NULL -- 15 min
consumed_at TIMESTAMP NULL
purpose TEXT NOT NULL -- 'initial' | 'recovery' | 'rectification'
created_at TIMESTAMP NOT NULL
sessions
id TEXT PK -- random 256-bit, stored hashed
user_id TEXT FK -> users.id ON DELETE CASCADE
credential_id TEXT FK -> webauthn_credentials.id -- binds session to the passkey that auth'd
issued_at TIMESTAMP NOT NULL
expires_at TIMESTAMP NOT NULL -- 12h rolling
revoked_at TIMESTAMP NULL
user_agent TEXT NULL
ip_prefix TEXT NULL -- /24 for IPv4, /48 for IPv6 (minimized)
audit_log
id INTEGER PK AUTOINCREMENT
actor_user_id TEXT NULL -- null = system
action TEXT NOT NULL -- 'user.register', 'session.issue', 'dsr.export', ...
target_kind TEXT NULL -- 'user' | 'credential' | 'session' | 'order'
target_id TEXT NULL
context TEXT NULL -- JSON, redacted
at TIMESTAMP NOT NULL
-- retention: 2 years (DPA requirement); see [ADR-0003](https://internal-docs.raxx.app/architecture/adr/0003-gdpr-by-default.html)
What is deliberately absent: any column that could store a password, TOTP seed, recovery code, SMS number, or phone. The absence is enforced by a CI grep (scripts/ci/check_no_credential_fields.sh) that fails the build if these column names appear in any migration.
4. APIs / contracts
All auth endpoints live under /api/auth/* in Raptor. All return JSON. CSRF is handled via same-site cookies + origin check; WebAuthn challenges are one-shot and bound to a server-side session.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/register/options |
Begin registration. Body: {email, display_name}. Returns PublicKeyCredentialCreationOptions. Server holds challenge in a short-lived cache (60s). |
| POST | /api/auth/register/verify |
Body: attestation response. Server verifies via py-webauthn, creates users row (unverified), webauthn_credentials row, and dispatches email verification. Returns {user_id, needs_email_verification: true}. |
| GET | /api/auth/email/verify?token=... |
Consumes the single-use token, sets email_verified_at. No auto-login (user must next complete WebAuthn login). |
| POST | /api/auth/login/options |
Usernameless / discoverable. Returns PublicKeyCredentialRequestOptions with empty allowCredentials. |
| POST | /api/auth/login/verify |
Body: assertion response. On success issues session cookie + returns {user_id, role}. |
| POST | /api/auth/logout |
Revokes current session. |
| POST | /api/auth/session/refresh |
Rolls a session within its rolling window; requires a fresh user-verified WebAuthn assertion every 24h (step-up). |
| POST | /api/auth/credentials/add/options |
Add a second device. Requires existing authenticated session. |
| POST | /api/auth/credentials/add/verify |
Completes second-device registration. |
| GET | /api/auth/credentials |
List the caller's passkeys (label, created_at, last_used_at). |
| DELETE | /api/auth/credentials/{id} |
Remove a passkey. The last remaining passkey cannot be removed without going through recovery. |
| POST | /api/auth/recovery/start |
Body: {email}. Always 202 (no account enumeration). Sends a single-use recovery link to the verified email. |
| POST | /api/auth/recovery/complete |
Consumes recovery token, walks user through new-passkey attestation. Revokes all prior credentials + sessions on success. |
GDPR / DSR endpoints (/api/gdpr/*, authenticated)
| Method | Path | Purpose |
|---|---|---|
| POST | /api/gdpr/export |
Async. Enqueues a per-user bundle (JSON + CSV). Notifies by email when ready. Link expires in 7 days. |
| POST | /api/gdpr/erase |
Async. Requires fresh WebAuthn step-up. Soft-deletes immediately, purges PII after 30-day cooling period, retains audit rows with hashed actor id for 2 years (legal obligation). |
| PATCH | /api/gdpr/profile |
Rectify display_name or initiate email-change flow (re-verification required). |
| GET | /api/gdpr/retention |
Returns the user-visible retention table. |
Admin
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/users |
List; admin only. Returns minimal PII. |
| GET | /api/admin/audit |
Query audit_log; admin only; every read itself written to audit_log. |
| POST | /api/admin/breach/notify |
Triggers the breach-notification runbook (see §8). |
5. Sequences
5.1 Registration
sequenceDiagram
participant U as User
participant A as Antlers (UI)
participant R as Raptor (API)
participant M as Mail provider
U->>A: Enter email, click "Create account"
A->>R: POST /api/auth/register/options {email}
R->>R: stash challenge (60s)
R-->>A: PublicKeyCredentialCreationOptions
A->>U: Browser prompts for Face ID / YubiKey
U-->>A: Attestation
A->>R: POST /api/auth/register/verify {attestation}
R->>R: py-webauthn verify, insert users + webauthn_credentials
R->>M: send verification email (one-shot token)
R-->>A: {user_id, needs_email_verification:true}
M-->>U: Email link
U->>R: GET /api/auth/email/verify?token=...
R->>R: set email_verified_at, consume token
R-->>U: "Verified. Please sign in."
5.2 Login (discoverable)
sequenceDiagram
participant U as User
participant A as Antlers
participant R as Raptor
U->>A: Click "Sign in"
A->>R: POST /api/auth/login/options
R-->>A: PublicKeyCredentialRequestOptions (allowCredentials:[])
A->>U: Browser shows passkey picker
U-->>A: Assertion
A->>R: POST /api/auth/login/verify {assertion}
R->>R: verify signature, update sign_count + last_used_at
R->>R: issue session (12h rolling)
R-->>A: Set-Cookie session=...; {user_id, role}
5.3 Recovery (primary passkey lost)
Email is the fallback channel — the only one. User hits "I lost my device", enters email, receives a single-use 15-minute link. Clicking it drops them into a new-passkey registration flow whose success atomically (a) attests a new credential, (b) revokes all existing credentials and sessions, (c) writes an audit row user.credentials_reset. The email-only gate is acknowledged as the weakest link and mitigated by inbox rate-limit, visible "we just reset your passkeys" notification, and a 72-hour cooldown before any new outbound trade can be placed from a newly recovered account (paper-first still gates live trading independently).
6. Session & API auth
- Session cookies are
HttpOnly,Secure,SameSite=Strict, 12-hour rolling, bound server-side to the credential that produced them (sessions.credential_id). - Server-side sessions (not JWTs) so revocation is instant.
- A fresh WebAuthn user-verified assertion is required every 24h and for every step-up action (erasure, credential removal, admin actions, live-trade unlock).
- External/API callers (e.g. a mobile client or CLI) obtain a short-lived API token (15 min) by performing a WebAuthn assertion against
/api/auth/api-token. Tokens are opaque, bound to the session id, and revoked when the session is. - No long-lived PATs in v1. When we need them, they will be passkey-attested and require per-use user-verification (separate ADR).
7. Multi-device
After login, POST /api/auth/credentials/add/options issues a registration challenge scoped to the already-authenticated user. The new device goes through normal WebAuthn attestation. Users can label devices ("MacBook", "Work YubiKey"). We recommend — but do not require — at least two registered devices; the UI nags on single-credential accounts.
8. Security & breach handling
- Rate limits: per-IP and per-email on
/register/*,/recovery/*,/email/verify. Bucket defined inbackend_v2/api/middleware/rate_limiter.pyconfig. - Account enumeration:
/recovery/startalways returns 202;/register/optionsreturns constant 202 for all outcomes. Exception: whenFLAG_SIGNUP_ALREADY_ENROLLED_REDIRECTis ON and the submitted email already has enrolled credentials, the 202 body carrieserror_code="email_already_enrolled"(HTTP status is still 202 — anti-enum invariant preserved at the status level; body discriminates) so the SignupForm can route the user to/login(#2993). - Breach notification pipeline: any write to
audit_logwith action prefixedbreach.*triggers a GitHub Action that pages on-call, files an Incident issue withseverity:critical, and starts the 72-hour GDPR Art. 33 clock. Runbook lives indocs/agents/security_response.md(extend it, don't duplicate here). - Secrets:
WEBAUTHN_RP_ID,WEBAUTHN_ORIGIN,MAIL_PROVIDER_KEY— all env vars, rotatable without redeploy via the secret store. - Kill-switch:
AUTH_DISABLED=1env flag short-circuits all/api/auth/*to 503 and logs every attempt, for use during an active incident.
9. Migrations & rollout
- Migration
0001_auth_identity.sql— creates the five tables. No data migration (new capability). - Dark launch: endpoints deployed behind feature flag
AUTH_V1=off. Integration tests run against the flag-on variant. - Internal beta: flag on for a fixed allowlist of emails. Exercise registration, multi-device, recovery, DSR.
- GA: flag on for all; legacy single-operator bypass removed in the same PR that flips GA.
- Rollback: flag-off restores prior behavior; data remains but is unreferenced.
10. Open questions (require user decision)
- Mail provider — SendGrid, SES, or Postmark? Cost, deliverability, and DPA terms differ. Blocks the email-verification sub-card.
- WebAuthn RP ID scope — is the production origin
app.trademaster.exampleor a different domain? RP ID is effectively immutable once users are registered. - Admin bootstrap — how is the first admin created? Proposed: CLI-only one-shot
python cli.py admin-bootstrap --email=...that requires DB file access and self-destructs the command after one success. - Retention period for paper-trading history — GDPR requires a defined period. Propose 3 years; needs user confirmation.
- Hosting region — GDPR data-residency expectation (EU-only? US OK?) drives mail provider region and backup storage region.
End of doc. See ADRs 0001–0003 for the decisions behind the choices above.