$npx -y skills add marckohlbrugge/37signals-skills --skill rails-security-multitenancyApply Rails security and multi-tenant safety practices including scoped queries, SSRF defenses, rate limiting, and tenant-scoped realtime updates. Use when implementing auth, webhooks, tenant boundaries, or security-sensitive endpoints.
| 1 | # Rails Security + Multi-Tenancy |
| 2 | |
| 3 | Use for security-sensitive Rails work and tenant-boundary reviews. Patterns from Fizzy (path-based multi-tenant SaaS) and Campfire (single-tenant, bot APIs). |
| 4 | |
| 5 | ## Core Rules |
| 6 | |
| 7 | - Scope all tenant data access through tenant/user ownership boundaries. |
| 8 | - Never trust naked `Model.find(params[:id])` in tenant-aware flows. |
| 9 | - Scope realtime broadcasts and stream names by tenant/account. |
| 10 | - Rate-limit auth and abuse-prone endpoints. |
| 11 | - Treat user-provided URLs as untrusted input. |
| 12 | - Fail closed (`head :forbidden`) when access cannot be proven. |
| 13 | |
| 14 | ## Tenancy Architecture (path-based, Fizzy-style) |
| 15 | |
| 16 | - Middleware extracts the account prefix from `PATH_INFO` into `SCRIPT_NAME`, loads the account, and wraps the request in `Current.with_account(account)`. URL helpers stay tenant-correct automatically (including ActiveStorage and webhook payload URLs via `script_name:`). |
| 17 | - **Three-layer auth: Identity → Session → account-scoped User.** Sessions attach to an `Identity`; each request resolves `Current.user = identity.users.find_by(account: Current.account)`. A valid session in account A can never act in account B. Apply the same resolution in ActionCable `Connection#connect`. |
| 18 | - `Current` setters cascade: assigning `session` resolves `identity`, assigning `identity` resolves `user` for the current account. |
| 19 | - Auth routes (login, signup, magic links) explicitly opt out of tenancy (`disallow_account_scope`) and redirect away from tenant-prefixed URLs. |
| 20 | - Path-scope session cookies (`path: account.slug`) when simultaneous multi-tenant login is supported. |
| 21 | - Recurring jobs run outside request context: iterate tenants explicitly (`with_each_tenant`). Serialize `Current.account` into job payloads (see rails-jobs). |
| 22 | - Test the tenancy middleware in isolation with `Rack::MockRequest`; integration tests set `default_url_options[:script_name]` from the fixture account. |
| 23 | |
| 24 | ## Scoped Lookups (defense in depth) |
| 25 | |
| 26 | Params choose *which* record within an already-authorized set — never establish access: |
| 27 | |
| 28 | ```ruby |
| 29 | @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) # access graph |
| 30 | @membership = Current.user.memberships.find_by!(room_id: params[:room_id]) # join model |
| 31 | @user = Current.account.users.find(params[:user_id]) # tenant association |
| 32 | ``` |
| 33 | |
| 34 | - Even single-tenant code scopes through associations; wrong IDs 404 naturally. |
| 35 | - Public sharing uses opaque tokens (`has_secure_token :key` on a `Publication` record), never internal IDs. |
| 36 | - ActiveStorage: attach blobs to accounts, and authorize blob/representation controllers through the domain (`blob → attachment → record.accessible_to?(user)`); published content gets an explicit `publicly_accessible?` path. |
| 37 | - Revoking access cleans up derived data (mentions, notifications, watches) via a scoped async job — don't leave dangling cross-boundary state. |
| 38 | |
| 39 | ## Authentication Hardening |
| 40 | |
| 41 | - Magic links / codes: single-use (consume destroys the row), short-lived, compared with `secure_compare`, bound to the email via a verified pending-auth cookie. |
| 42 | - Anti-enumeration: unknown email gets the same fake flow/UX as a real one. |
| 43 | - API tokens: HTTP-method-scoped permissions (read-only tokens can't POST); show generated secrets once via a short-lived message verifier (~10s), then never again. |
| 44 | - Bot/automation auth as an explicit mode: skip CSRF only for bot-key auth, deny bots everywhere by default (`deny_bots` + `allow_bot_access only:`). |
| 45 | - Rate limit with Rails built-ins, with responses matching endpoint semantics: |
| 46 | |
| 47 | ```ruby |
| 48 | rate_limit to: 10, within: 15.minutes, only: :create, |
| 49 | with: -> { redirect_to ..., alert: "Try again in 15 minutes." } |
| 50 | ``` |
| 51 | |
| 52 | - Throttle session bookkeeping writes (update `last_active_at` at most hourly). |
| 53 | - Filter sensitive params beyond passwords: message bodies, push endpoints, tokens. |
| 54 | |
| 55 | ## SSRF Defense Baseline |
| 56 | |
| 57 | For webhooks, push endpoints, unfurling — any user-influenced URL: |
| 58 | |
| 59 | - Resolve DNS and validate the destination IP before the request; block loopback/private/link-local/IPv4-mapped-IPv6 ranges (link-local = cloud metadata). |
| 60 | - Pin the request to the validated IP (`Net::HTTP.new(host, port, ipaddr: resolved_ip)`) to beat DNS rebinding. |
| 61 | - Validate at creation time and again at execution time. |
| 62 | - Re-resolve and re-validate on every redirect hop — redirect chains are the classic bypass. |
| 63 | - Cap response sizes (content-length pre-check + chunked read limit) to prevent memory DoS. |
| 64 | - Layer allowlists on top where the destination set is known (web push: permitted vendor host suffixes AND public-IP resolution). |
| 65 | |
| 66 | ## CSRF and Caching |
| 67 | |
| 68 | - Never HTTP-cache pages that render forms/CSRF tokens. |
| 69 | - Add `Sec-Fetch-Site` verification |