Idempotency-Key required-from-day-one, lockout escalation. Each has a clear right answer; leaving them open turns implementation into re-litigation.Six sub-issues address six layers. Layering is canonical — each layer addresses what the others can't see.
WAF managed rules on auth paths, per-path rate-limit rules (Pro tier), bot scoring, and (critically) Render origin IP allowlist restricting traffic to Cloudflare's IP ranges.
Per-IP and per-email throttles on /login / /create-account / /reset-password-request; per-account cap on POST /property_reports; catch-all backstop on state-changing /api/v1/*.
Guards::RateLimit in the pipeline — answer is "both" (cheap IP rejection at rack; account-scoped throttle at guard).:lockout + enumeration close-outEnable Rodauth's :lockout feature (5 invalid logins → lock). Generic error messages on login + reset-password to close the account-enumeration oracle (currently distinguishes "no such account" from "wrong password").
:lockout alone hands an attacker a DoS vector against any known tenant email.:lockout for v1 + flag the DoS vector in the ADR; revisit with CAPTCHA when a real abuse signal arrives.Puma worker_timeout 15, threads tuned to DB pool, max-body-size middleware (1MB JSON / 50MB multipart), explicit open_timeout + read_timeout on Sources::* outbound HTTP.
Required Idempotency-Key header on POST/PATCH/DELETE; Solid Cache stores response under (key, method, path, account) hash for 24h; conflict detection on key-reuse with different body. Guards::Idempotency in pipeline.
required: true from day one or required: false with soft-deprecation? Recommend false-then-true to keep partner / B2B clients integratable.Guards::UpstreamBudget caps cumulative outbound source calls per account in a rolling window. Solid Cache for hot-path check; ProviderBudgetLedger table for audit on trip events.
Without restricting Render to Cloudflare IP ranges, every other rate-limit is bypassable by hitting Render directly. Currently buried inside #427 as "Origin protection." Works on Cloudflare Free tier — no plan upgrade needed. Single highest-leverage / lowest-cost fix in the whole stack.
CriticalThe umbrella sequences #429 in the second parallel wave, behind #424. That ships rack-attack as a regression first, then fixes the UX problem. Flip the order: idempotency makes the rate-limit safe.
Critical#424's "per-email 5/5min" throttle is a second account-enumeration oracle — only registered emails accumulate a per-email counter. #425 closes the content oracle ("Invalid login or password"); doesn't close this behavioural one. Mitigation: per-email counter for any submitted email, including unknown.
High"Allowlist Stripe IPs" — Stripe's source IPs are broad and rotate, allowlist gets stale. The actual gate is Stripe-Signature verification plus WebhookEvent.event_id dedup. Rate-limit only signature-invalid requests; verified ones pass.
"30 reports / hour" (velocity #424) × "5 sources per report" = 150 calls — but #428's breaker is 100 calls. Either the breaker counts reports not calls, or 30 is too many. Pin in the ADR.
HighIdempotency-Key: required: true from day one is over-strictPWA auto-generation is invisible; native is fine. But forecloses partner / B2B integrations later — naive integrators see 400s. Recommend required: false with a 6-month soft-deprecation before flipping.
:lockout hands an attacker a tenant-DoS vectorAccount-level lockout (Rodauth default) means an attacker who knows a tenant's email can lock them out with 5 wrong passwords. Standard mitigation is per-IP-per-account CAPTCHA before account-level lockout. Worth flagging in the ADR even if you keep :lockout for v1.
Authenticated attacker pounding mutating endpoints generates audits rows linearly. Over time → DB-size DoS. Audit retention policy (probably in data_governance pack) is the right home for a cap.
Solo founder will convince themselves the stack works without proving it. Pin a tool (hey, k6, or wrk) and write the exact commands in the ADR. External pen test is a post-pilot task.
Free tier + global "Under Attack" mode + origin allowlist + rack-attack covers the realistic threat surface pre-pilot. Pro buys per-path rate-limit rules — a tightening, not a foundation. Defer until pilot tenants exist.
MediumThe umbrella's "three-then-three parallel" plan is roughly right but mis-orders the foundations. Suggested re-sequence:
| Wave | Issues | Why | Working days |
|---|---|---|---|
| 0 — prereq | Cloudflare origin allowlist (split from #427) | Without this, nothing else matters. Render dashboard + Cloudflare IP list, ~30 min. | 0.5 d |
| 1 — parallel | #429 Idempotency · #426 Puma/body/pool · #425 Rodauth lockout + enumeration | Idempotency before rack-attack; Puma + lockout don't conflict; all three touch different files. | ~3 d |
| 2 — parallel | #424 rack-attack · #428 upstream-budget breaker | Both build on Wave 1. Different layers, no file overlap. | ~3 d |
| 3 — later | #427 Cloudflare WAF + RL rules (remainder, Pro tier) | Defer until pilot tenants make Pro spend justifiable. | 0.5–3 d |
| 4 — DoD | ADR docs/05-design/api-rate-limit-policy.md + pinned-tool abuse run |
Closes the umbrella; commits the trade-offs. | 1 d |
MVP-for-pilot slice: Wave 0 + Wave 1 + Wave 2 + Wave 4 — ~7–8 working days. Defers Wave 3 entirely until traffic justifies it. Realistic in 2 calendar weeks alongside other work.
ProviderBudgetLedger table for audit. (Issue already suggests both.)Idempotency-Key: required — false with 6-month soft-deprecation.account.lockout notification via #348; account-level lockout for v1; per-IP-CAPTCHA flagged as future tightening.