Defense-in-Depth to Production
Status: Design — pre-launch hardening baseline
Owner: software-architect
Date: 2026-07-11 UTC
Pair review: security-agent (adversarial review follows this doc)
Related: docs/plan-to-production.md (parallel PM doc); ADR-0138 (idempotency); ADR-0137 (wp-config-svc); ADR-0130/0131 (agent identity); docs/architecture/customer-audit-unified/design.md
Hardening epic: #94 (Production Release v1.0)
1. Preamble — design invariants this doc enforces
Every control described here is subordinate to these non-negotiable platform invariants. A design that requires violating any of them is not a design; it is a conflict that must be escalated.
- I-1 No stored credentials. No auth token, passkey secret, OAuth credential, or broker API key is ever persisted in a form that can be replayed.
- I-2 Passkeys / WebAuthn only. No password column exists or ever will. No SMS OTP. No email OTP as a primary factor.
- I-3 Paper-first gate. Live-trading code paths require the graduation gate or explicit per-flow override. The gate cannot be short-circuited by an idempotency replay, a support token, or a feature flag misconfiguration.
- I-4 Audit trail. Every state change affecting money, permissions, or data access is written to the append-only audit chain with HMAC-KMS integrity, before the response is returned.
- I-5 Credentials in infra. API keys live in Infisical vault or AWS SSM. Never in application code, logs, or Heroku config set without masking.
- I-6 GDPR by default. Retention limits, DSR (access / portability / erasure / rectification), DPA-ready logging, 72-hour breach notification clock.
- I-7 Defense-in-depth. No single control failure is fatal. Every surface must have at least two independent controls; where only one layer currently exists, that is a hardening card.
2. Per-surface threat model
2.1 Order-firing / trading path
Assets: paper positions, paper cash balance, audit chain, live broker credentials (future).
Trust boundary: Authenticated Raptor session (raxx_session cookie) → session_auth → require_session → optional idempotency_key decorator → handler → MbtFillEngine → paper_orders table → audit write.
| Threat (STRIDE) | Top risks | Layers today | Single-layer gaps |
|---|---|---|---|
| Spoofing | Replaying another user's idempotency key to execute their order | Key scoped to (idem_key, scope_user_id, scope_endpoint) — cross-user key collision produces no conflict |
None — user scope isolates correctly |
| Tampering | Modifying request body to change order parameters while reusing a key | request_hash = SHA-256(sorted JSON body); mismatch → 422 |
Hash of None/empty body undefined (see HC-ORD-1) |
| Repudiation | Denying an order was placed | Audit write before response; replay tagged as action='idempotency_replay' (distinct row) |
Stale in-flight leaves orphan execution with no stored response (HC-ORD-2) |
| Info-disclosure | Response body leaks order timing/amounts to unauthorized reader | response_body JSONB in idempotency_keys table; visible to any Postgres reader with table access |
Column-level encryption deferred (open question ADR-0138 OQ2) |
| DoS | Flooding idempotency table to fill Postgres | 24h TTL + nightly cleanup job; table has per-user index; fail-closed 503 on Tier 1 preserves safety | Cleanup job failure is silent; no per-user key-count cap (HC-ORD-3) |
| Elevation | Bypassing paper-first gate via idempotency replay | Gate checked inside handler on first execution; replay returns stored response from an already-gated execution. Replaying a paper order does not unlock live. | FLAG_LIVE_MODE_RBAC_GATE=0 (default off) means RBAC gate is not enforced on the live path (HC-ORD-4) |
Order-firing path deepest treatment — see Section 3.
2.2 AuthN / AuthZ surface
Assets: WebAuthn credential bindings, session tokens, recovery flow, RBAC role grants.
Trust boundary: Browser → Cloudflare WAF → Raptor /api/auth/* → py-webauthn → webauthn_credentials + sessions tables.
| Threat | Top risks | Layers | Single-layer gaps |
|---|---|---|---|
| Spoofing | Authenticating as another user by forging an assertion | py-webauthn signature verification + RP ID pinned to raxx.app + sign count check |
Challenge storage is in-memory per-process; multi-dyno Heroku cannot validate a challenge issued on a different dyno (HC-AUTH-1) |
| Tampering | Flipping role or session user_id in a cookie |
Session cookie is HMAC-signed; raw token stored only as SHA-256 | SameSite=Lax (not Strict) allows cross-site top-level navigation to carry the cookie (HC-AUTH-2) |
| Repudiation | Denying a login or credential registration | Every auth event in audit_log; every 401 written with path and reason |
— |
| Info-disclosure | Account enumeration via /register/options |
Always returns 202 | FLAG_SIGNUP_ALREADY_ENROLLED_REDIRECT leaks email_already_enrolled in the 202 body — acceptable per ADR but documented here as a one-layer exception |
| DoS | Brute-force credential recovery | Per-IP and per-email rate limits on /recovery/* and /register/* |
Rate limit bucket config not documented in infra runbooks — needs ops visibility |
| Elevation | Recovery email token used by inbox-level attacker | 15-min TTL, single-use, SHA-256 stored | Inbox compromise is the single path to full account reset; 72-hour new-account live-trade cooldown is the backstop. Accepted residual risk. |
Internal machine-auth paths (/api/internal/*):
Multiple auth patterns co-exist: HMAC token (FreeScout webhook), Bearer token (WAF events), X-Admin-Service-Token (jobs, merges). The X-Admin-Service-Token is a single shared secret. If it leaks, all /api/internal/jobs/* and /api/internal/merges* routes are compromised. No per-route isolation or per-caller identity today. See HC-AUTH-3.
2.3 Billing / payments
Assets: Stripe checkout sessions, Apple IAP receipts, subscription state, billing action log.
| Threat | Top risks | Layers | Single-layer gaps |
|---|---|---|---|
| Spoofing | Fake Stripe webhook event | STRIPE_WEBHOOK_SECRET HMAC signature verification by Queue service (ADR-0127) |
If webhook secret is not rotated on disclosure, old events remain valid indefinitely |
| Tampering | Double-checkout (two Stripe sessions for one action) | Idempotency-Key on billing endpoints (Tier 2 fail-open) + Stripe-side idempotency key (Queue layer) | Fail-open means both layers can be bypassed simultaneously if the idempotency store is down |
| Repudiation | Denying a billing action was taken by an operator | console_billing_action_log with admin_id, action_type, created_at |
Log table has no KMS HMAC integrity — it can be mutated by the DB owner role |
| Info-disclosure | Apple IAP server-notification containing subscriber state | Dispatcher dedup on notification_uuid |
IAP endpoint auth model (Apple IP range or JWT-signed notifications per StoreKit 2) not documented (HC-BILL-1) |
| DoS | Spinning up Stripe checkout sessions at rate to exhaust budget | Rate limit on /api/billing/checkout-session |
Per-user rate limiting on checkout not confirmed today |
| Elevation | Attacker grants themselves Pro+ via crafted billing event | Subscription state changes require Stripe event verification; mirror-sync is UPSERT | No second-factor step-up for tier upgrade — acceptable for billing tier changes |
2.4 Edge / perimeter
Assets: Heroku origin URL, vault.raxx.app, ci.moosequest.net, CI pipeline config.
| Threat | Top risks | Layers | Single-layer gaps |
|---|---|---|---|
| Spoofing (CF bypass) | Direct-to-origin request skipping CF WAF, BFM, rate limits | FLAG_ENFORCE_CF_ORIGIN checks CF-Connecting-IP presence |
Only checks header presence, not source IP against CF published ranges. Attacker who can set the header on a direct-to-origin request bypasses the guard. HC-EDGE-1. |
| Tampering | Malicious PR modifying .woodpecker/ pipeline files to exfiltrate CI secrets |
wp-config-svc signs config; WP validates Ed25519 JWT |
PR pipeline runs before merge with PR author's code; if secrets are injected into the PR pipeline context, malicious .woodpecker/*.yaml modifications can read them. HC-EDGE-2. |
| Spoofing (CF Access) | Machine caller spoofing CF Access service token | CF Access non_identity policy + Infisical machine identity (ADR-0130) — two independent gates |
BFM does not recognize CF Access service tokens; WAF skip rule required (see feedback: cf_access_does_not_bypass_bot_fight_mode) |
| DoS | Woodpecker forge context deadline (the July-09 incident) | wp-config-svc reduces per-event GitHub API calls from 80 serial to 1+N parallel |
Still in Phase 0 shadow mode; fallback to native forge is the current active path |
| Elevation | WP admin token leaked from SSM grants full Woodpecker API access | SSM SecureString + CloudWatch audit | Single admin token; no per-caller scope on the WP admin API |
2.5 Secrets — Infisical vault + SSM + agent identity
Assets: All service credentials (Stripe, Postmark, Heroku, Apple IAP, broker OAuth), GitHub App private keys, agent machine identities.
| Threat | Top risks | Layers | Single-layer gaps |
|---|---|---|---|
| Disclosure | Vault credential read by unauthorized agent | CF Access outer gate + Infisical RBAC per identity (ADR-0130) | Header-injecting proxy holds CF service token in env — proxy process compromise exposes the token |
| Tampering | Credential rotation falsely reported as successful | AFFECTED_SITES propagation + audit trail |
Bug pattern from #986 (closed): silent skip of non-Heroku AFFECTED_SITES entries. Durability of fix must be verified. |
| Repudiation | Agent reads secret with no attribution | Per-agent machine identity → Infisical audit log per-identity | Infisical audit log retention is 90 days; export to append-only chain is deferred (ADR-0130 OQ2) |
| Info-disclosure | Multi-line PEM key partially leaks in CI logs | ::add-mask:: only masks the first line |
Per project memory: mask line-by-line for PEM keys; any key seen in logs must be rotated |
| Elevation | Handoff agent inherits admin identity | HANDOFF-INVARIANT-1 (ADR-0131): per-task scoped identity, never admin | SC-HANDOFF-GATE-01 provisioning script not yet shipped; manual checklist is the current gate |
2.6 Data — audit log, idempotency_keys, GDPR
Assets: customer_audit_events, audit_log, idempotency_keys, PII in users, shadow tables, GDPR DSR machinery.
| Threat | Top risks | Layers | Single-layer gaps |
|---|---|---|---|
| Tampering | Insider modifies audit rows | REVOKE UPDATE, DELETE from raptor_app role (invariant I-11); HMAC-SHA-256 + KMS chain (invariant I-9) |
DATABASE_URL (owner role) retains full DDL and DML. Owner role must not be the running application credential. HC-DATA-1. |
| Repudiation | Claiming an audit event never happened | Append-only DDL + HMAC chain; SC-A17 CI gate enforces action namespace allowlist | HMAC write behavior on KMS unavailability (fail-open vs fail-closed) not documented. HC-DATA-2. |
| Info-disclosure | PII retained past DSR erasure window | Soft-delete + 30-day cooling + pseudonymization; audit rows retain hashed actor_id for 2 years |
If actor_id hash uses low-entropy input alone, hash may be reversible. HC-DATA-3. |
| DoS | idempotency_keys cleanup job fails silently |
Nightly job at 04:00 UTC | No alerting on cleanup job failure; silent table growth degrades query performance. HC-ORD-3. |
3. Order-firing path — deep threat model
The idempotency mechanism is the primary correctness control on the path that moves paper money. The following findings represent the highest-confidence attack or failure vectors.
3.1 In-flight race: business logic success + UPDATE failure (double-fire)
Threat: Handler executes successfully — paper_orders row created, audit written — but the UPDATE SET status='completed' fails before commit (DB connection lost, gunicorn worker killed at exactly the wrong moment).
Effect: Row stays in_flight. After 30 seconds, a retry sees stale_in_flight → 409 with no stored response. User retries with a new idempotency key → second order placed. This is a double-fire on the paper portfolio.
Why the 30s stale detection doesn't fully protect here: The stale detection prevents re-execution via the same key. It does not prevent the user from issuing a new key after seeing the 409. There is no "check your orders before retrying" signal in the current 409 response body.
Hardening (HC-ORD-2):
1. The handler's side effects (paper_orders INSERT, audit write) and the idempotency UPDATE must be wrapped in the same DB transaction. If the UPDATE fails, the handler's side effects roll back. At-most-once is then guaranteed by the DB commit boundary.
2. If transaction wrapping is not possible for a specific handler (e.g., it calls an external broker), add a pre-commit idempotency check: before executing the external call, mark status='executing' (new intermediate state) with a savepoint; on external call success, complete the UPDATE in the same transaction block.
3. The stale_in_flight 409 response body must include "check_order_status": true to prompt the client to inspect GET /api/trading/orders before retrying with a new key.
4. Document as a v1 known limitation in the trading panel UI.
3.2 Request-hash normalization undefined for edge inputs
Threat: get_json(silent=True) returns None on malformed body or wrong Content-Type. The current spec calls for SHA256(sorted_json_body) but does not define behavior when the body is None.
Effect A (real risk): None body hashes to a constant string (e.g., SHA256("null")). A legitimate retry after a timeout that produced no body will hash to that constant and trigger a 422 body-mismatch against the original non-null hash. Client retries with a new key → double-fire.
Effect B (security): Two requests with empty bodies produce the same hash regardless of intent, collapsing them to one idempotent operation. Harmless for order endpoints (an order with no body should be rejected by the handler before idempotency anyway), but worth documenting.
Hardening (HC-ORD-1): Before computing the hash, validate body = request.get_json(silent=True) is a non-None dict. If not, return 400 before the idempotency check runs. Define canonical serialization as json.dumps(body, sort_keys=True, separators=(',', ':'), ensure_ascii=True). Add an integration test that submits the same body with different whitespace and confirms 200+replay on retry.
3.3 Live-mode gate: FLAG_LIVE_MODE_RBAC_GATE off by default
Threat: The @require_live_mode_role decorator is a transparent passthrough when FLAG_LIVE_MODE_RBAC_GATE=0. The flag is off in the current default configuration. If a live broker credential were accidentally activated, any authenticated user could reach the live order path with no RBAC gate.
Assessment: The paper-first graduation gate (paper_profitable_gate_met) is a separate mechanism checked inside the handler. However, it is distinct from the RBAC gate and may have its own failure modes. Two independent gates are stronger than one.
Hardening (HC-ORD-4): FLAG_LIVE_MODE_RBAC_GATE=1 must be set on both staging and prod before any live broker credential is activated. This is a pre-launch configuration requirement, not a code change.
3.4 Replay after live-mode access revocation
Threat: User is granted live-mode access, places a live order (key K), access is revoked. User retries with key K within the 24h TTL — the stored replay response is returned. The live order confirmation is replayed without re-checking current access.
Assessment: This is correct behavior within the design. The replay returns a stored response from an execution that was authorized at the time. The 24h window is the exposure period after revocation.
Hardening (HC-ORD-5): On live_mode_grants revocation, proactively set expires_at = now() on all idempotency_keys rows for that user on POST /api/trading/orders and POST /api/trading/orders/ic. This collapses the replay window to zero on revocation rather than waiting for TTL expiry.
3.5 Decorator stack ordering: missing runtime assertion
Threat: If @idempotency_key is applied before @require_session, g.current_user_id is not set. The middleware falls back to scope_user_id = NULL, collapsing all service-call keys into the same namespace. Two users with key "abc" on the same endpoint would conflict.
Hardening (HC-ORD-6): Add a runtime assertion in the decorator: if g.current_user_id is not set when fail_closed=True, raise RuntimeError("idempotency_key applied before require_session"). This surfaces misconfiguration at development time (Sentry alert, test failure) rather than silently in production.
4. Residual-risk register
Must close before launch (7 items)
| ID | Surface | Risk | Action |
|---|---|---|---|
| HC-AUTH-1 | Auth | WebAuthn challenge in-memory on multi-dyno → auth non-deterministic on >1 dyno | Move challenge store to Postgres webauthn_challenges table or Redis before any dyno scaling |
| HC-EDGE-1 | Edge | CF-Connecting-IP header presence check only — direct-to-origin bypass by header injection |
Implement Phase 2: validate source IP against CF published IP ranges |
| HC-EDGE-2 | CI | PR pipeline .woodpecker/*.yaml modifications can exfiltrate secrets injected in PR context |
Confirm and restrict Woodpecker secret injection scope to trusted branches only, not PR events |
| HC-ORD-2 | Order | Business logic success + idempotency UPDATE failure = orphan execution, new-key retry = double-fire | Wrap handler + idempotency UPDATE in same DB transaction; add recovery UX guidance |
| HC-ORD-4 | Order | FLAG_LIVE_MODE_RBAC_GATE=0 default — live trading path has no RBAC gate if live cred is activated |
Set FLAG_LIVE_MODE_RBAC_GATE=1 on staging and prod before any live broker credential is configured |
| HC-DATA-1 | Data | DATABASE_URL (owner role) used by running app bypasses REVOKE UPDATE/DELETE on audit tables |
Verify Raptor connects as raptor_app role, not the owner credential; add CI assertion |
| HC-BILL-1 | Billing | Apple IAP server-notification endpoint auth model undocumented | Document and enforce Apple server-notification origin validation (Apple IP range or JWT per StoreKit 2) |
Accept for v1 (9 items)
| ID | Surface | Risk | Accepted because |
|---|---|---|---|
| HC-AUTH-2 | Auth | SameSite=Lax allows top-level cross-site navigation to carry session cookie | Required for discoverable passkey UX; origin check on state-mutating routes is already present |
| HC-AUTH-3 | Auth | X-Admin-Service-Token is shared across all /api/internal/* routes |
Pre-launch volume is low; rotation on disclosure is the mitigation; per-route isolation is post-launch |
| HC-ORD-1 | Order | Request body hash normalization undefined for None/empty body | Add explicit 400 before hash step; low exploitability — all instrumented endpoints expect non-empty JSON |
| HC-ORD-3 | Order | Idempotency cleanup job failure is silent; no per-user key-count cap | Table indexed; 24h TTL limits growth; alerting on cleanup job failure is a post-launch improvement |
| HC-ORD-5 | Order | Revoked live-mode access does not expire existing idempotency_keys replay rows within 24h TTL | Operator must cancel orders on revocation (document in live-mode revocation runbook) |
| HC-ORD-6 | Order | Wrong decorator order is silent in production | Add runtime assertion in decorator; low risk given small endpoint list at v1 |
| HC-DATA-2 | Data | HMAC write behavior on KMS unavailability not documented | KMS unavailability is an AWS outage; document fail-closed behavior as the required posture |
| HC-DATA-3 | Data | actor_id hash in 2-year audit rows may be reversible for low-entropy inputs |
Hash input includes user_id integer + action + timestamp — not email-only; residual risk is low |
| HC-EDGE-3 | Edge | wp-config-svc still in Phase 0 shadow mode; native forge is the active path |
Phase 0 is the designed safe rollout path; fallback to forge is always available |
5. Security considerations — GDPR checklist
- PII in this design: This is an architecture doc only. Per-surface PII handling is in source designs (
auth.md §8,customer-audit-unified/design.md §I-3,[ADR-0138](https://internal-docs.raxx.app/architecture/adr/0138-idempotency-key-mechanism.html) §Security). - Breach notification: The 72-hour Art. 33 automation is triggered by
audit_logwrites withaction='breach.*'; documented indocs/agents/security_response.md. This doc does not change that path. - Kill-switches per surface:
MBT_NEW_ORDERS_DISABLED(order path) ·AUTH_DISABLED=1(auth) ·FLAG_BILLING_SUMMARY_API=0(billing) ·FLAG_ENFORCE_CF_ORIGIN=0(edge) · per-identity Infisical revocation < 60s (secrets). - No new secrets introduced by this design document.
6. Open questions (operator decisions required before sub-cards are claimed)
- HC-AUTH-1 timing: Raptor is currently on 1 dyno. At what user volume does scaling trigger? This determines whether the Postgres challenge store migration is pre-launch or post-launch.
- HC-EDGE-2 Woodpecker PR secret scope: Does the current Woodpecker configuration inject production secrets into PR-context pipeline runs?
sre-agentto verify before launch. - HC-DATA-1 role confirmation: Is the Heroku
DATABASE_URLcredential used by the running Raptor dyno theraptor_approle or the owner role?sre-agentto runSELECT current_user;on prod DB. - response_body encryption (ADR-0138 OQ2): Encrypt
idempotency_keys.response_bodyat column level (pgcrypto), or accept DB-level readers see order details? Operator decision required before Wave 3 rollout.