Skip to content

Latest commit

 

History

History
253 lines (213 loc) · 18.2 KB

File metadata and controls

253 lines (213 loc) · 18.2 KB

Rails Multi-Tenant Architecture Guide

This guide tells every coding agent exactly how to build and extend a multi-tenant Rails SaaS. Follow it before touching code, and keep it updated whenever conventions change.


1. Core Principles

  • Monolithic Rails: One app, one database (per environment). Prefer server-rendered HTML with Hotwire (Turbo + Stimulus) for interactivity.
  • Opinionated defaults: Use importmap, Propshaft, Tailwind (or utility CSS), Solid Queue, Active Storage, Active Record encryption, and Rails’ CurrentAttributes.
  • Multi-tenancy first: Every feature assumes a tenant-aware request cycle. Cross-tenant leaks are treated as security bugs.
  • Small, reversible changes: Ship tiny commits with behavioral tests and plain-English intent. Remove dead code on sight.
  • Security and performance baked in: Run Brakeman/Bundler-Audit, encrypt sensitive columns, cache aggressively, and minimize transactions.

2. Multi-Tenant Context

  • Tenant identification: Use a URL path prefix (/{account_slug}/...) or subdomain. Parse it in Rack middleware and set Current.account. Never infer account from arbitrary params later in the request.
  • Current attributes:
    class Current < ActiveSupport::CurrentAttributes
      attribute :account, :user, :session, :request_id, :user_agent
    
      def with_account(value, &block)
        set(account: value, &block)
      end
    end
  • Routing: Mount the app under the slug by rewriting SCRIPT_NAME/PATH_INFO in middleware so controllers can use normal resources.
  • Background jobs: Prepend an Active Job extension that serializes Current.account (via Global IDs) and restores it in perform. Reject jobs that run without a tenant.
  • ActionCable/Turbo: Pass the slug in stream/channel identifiers. Turbo Streams rendered in jobs must use a renderer initialized with script_name: Current.account.slug.

3. Domain Modeling

  • Foundational models:
    • Account: represents the tenant. Include settings JSON, branding, quotas, join rules.
    • Identity: global user identity (email); may belong to multiple accounts.
    • User: account membership. Stores role (owner, admin, member, system), status, preferences.
    • Domain objects (boards/cards, rooms/messages, etc.) always include account_id as a foreign key and default scope/filter by account.
  • Primary keys: Use UUIDv7 everywhere (primary_key: :uuid), including join tables. Configure adapters to treat uuid columns properly for MySQL/SQLite/Postgres and set migration defaults to -> { "uuid_to_bin(uuid_generate_v7())" } (or the adapter equivalent). Provide deterministic fixtures.
  • Settings & flags: Use has_json :settings-style helpers or store_accessor to keep tenant/user settings flexible. Always validate JSON schema defaults.
  • Associations: Keep dependent: rules explicit (e.g., destroy vs delete_all). Ensure cascading deletes respect tenant isolation.
  • Component library:
    • Adopt Shadcn Rails components (Tailwind + Radix) as the default UI kit. Before creating new UI, inventory existing Shadcn components (buttons, dialogs, dropdowns, forms, cards) and reuse them.
    • If a needed component doesn’t exist, extend Shadcn’s design language: create the component, document it, and upstream it to the shared library.
    • Keep custom utility classes minimal; rely on Shadcn tokens/variants for consistent styling.
  • Concerns & services:
    • Create a concern only if two or more models/controllers share non-trivial behavior (tagging, workflowing, search indexing, notification dispatch).
    • Name concerns after the domain capability (Taggable, Workflowing, AccountScoped) and place them under app/models/concerns or app/controllers/concerns.
    • Concerns must encapsulate real logic (callbacks, scopes, helper methods). If a concern just delegates or aliases a single method, inline it.
    • Document each concern’s expectations (required associations, callbacks) in code comments and tests to prevent misuse.

4. Authentication & Authorization

  • Sessions: passwordless magic links or short-lived codes per identity. Sessions table stores token, user agent, IP, account_id.
  • Join flow: invite codes scoped to account, with rate limits and usage counters. Join endpoints must verify the code before creating a membership.
  • Roles: enum role: %i[owner admin member system]. Gate controller actions with dedicated concerns (AccountScoped, BoardScoped, etc.) and policy helpers.
  • RBAC pattern:
    • Store role on the account membership (User model) rather than the global identity so permissions remain tenant-specific.
    • Use enums for coarse roles, and feature flags/settings for finer permissions (e.g., account.settings.restrict_room_creation_to_admins?).
    • Centralize permission checks in policy helpers or services (Permission.for(user).can_manage?(resource)), but keep controllers/models readable: authorize! :manage, @card.
    • Include integration tests for each protected action: owner/admin/member/system, plus banned/deactivated states.
    • When introducing a new capability, add both a policy method and a feature toggle in account settings so tenants can adjust without code changes.
  • Bots & API access: If bots exist, use explicit tokens scoped to account and restrict endpoints via before_actions (allow_bot_access).
  • Verified communication: Track verified_at on users/identities. Only send email or push notifications to verified addresses/devices. Surface verification status in UI.

5. Collaboration & Domain Features

  • Resources: Keep nested routes for domain hierarchies (e.g., resources :boards do ... resources :cards). Use namespaced controllers for sub-resources.
  • Activity streams: Store immutable Event records describing who did what. Use JSON particulars for payloads to power audit logs, notifications, and webhooks.
  • Watchers/notifications: Model Access or Subscription records that tie users to resources (boards, rooms). Provide UI toggles for involvement levels (“watching”, “mentions only”).
  • Entropic cleanups: Periodically archive stale objects (cards/rooms) via background jobs to keep interfaces tidy.
  • Subscription management:
    • Track Plan, Subscription, and Invoice models per account. Store status (trialing, active, past_due, canceled), billing cycle dates, and payment provider identifiers.
    • Sync billing events via webhooks from the payment provider (e.g., Stripe). Validate payload signatures, enqueue jobs to update subscriptions, and log events for auditing.
    • Surface subscription state in the admin area: plan name, seats used vs. limit, next renewal date, payment method status.
    • Enforce limits at the application layer (e.g., Account::SeatLimitExceeded), blocking creation of users/resources when a subscription is inactive or over quota.
    • Provide lifecycle emails/notifications: trial ending, payment failed, subscription canceled. Gate them behind verified contacts.
    • Allow owners to upgrade/downgrade plans in-app. Use background jobs to apply plan changes and update seat counts atomically.
    • Implement grace periods for failed payments; restrict access incrementally (e.g., read-only after X days, lock after Y days).
    • Document pricing models (flat, per-seat, usage-based). When introducing a feature, specify which plan tiers include it and how usage is measured. Ask product owners to clarify plan eligibility before implementing premium-only functionality.

6. User Interface & Experience

  • Render-first Hotwire flow:
    1. Return full HTML responses for simple interactions.
    2. Wrap sections in Turbo Frames when only part of the page should refresh.
    3. Use Turbo Stream templates for server-side broadcasts that update multiple clients.
    4. Introduce Stimulus controllers only when client-side state or complex gestures are unavoidable (drag/drop, keyboard navigation, timers). Document which approach you picked and why in code comments or review notes when it isn’t obvious.
  • Stimulus conventions:
    import { Controller } from "@hotwired/stimulus"
    
    export default class extends Controller {
      static targets = ["panel"]
      resize() { this.panelTarget.style.height = `${window.innerHeight - 120}px` }
    }
  • Option evaluation: Document whether an interaction uses plain requests, Turbo Frames, or Streams. Pick the simplest variant that satisfies latency and collaboration needs.
  • Accessibility & UX guardrails:
    • Provide ARIA roles, labels, and keyboard shortcuts for interactive elements (dialogs, menus, hotkeys).
    • Manage focus explicitly when dialogs/menus open/close; return focus to the invoking element.
    • Ensure color choices meet WCAG contrast requirements; document tokens (primary, secondary, accent) in a CSS variables file.
    • Verify forms and critical flows with system tests that tab through controls and assert focus states.
  • Responsive layout:
    • Use mobile-first CSS (Tailwind utilities or equivalent). Verify breakpoints at narrow (<480px), tablet (~768px), and desktop (>1024px) widths.
    • Avoid hiding critical actions on mobile; collapse into menus only when documented.
  • Copy & guidance:
    • Use plain, action-oriented copy (“Watch this board”, “Invite member”). Note any dynamic text in translation files if i18n is enabled.
    • Surface destructive actions with confirm dialogs and clear labels (“Delete board permanently”).
  • View composition:
    • Keep partials at consistent abstraction levels. Prefer helpers when markup is simple and repeated.
    • Use dom_id helpers for stable targets, and always set explicit id attributes on Turbo Frames.
    • Favor Shadcn-supplied components in ERB/partials; when custom markup is necessary, style it with Shadcn’s CSS tokens (e.g., btn, badge, card).
    • Document reusable components (buttons, menus, cards) in a UI README or Storybook-like doc so agents know when to reuse vs. create new.
  • Turbo Stream templates: Store under app/views/.../*.turbo_stream.erb. Even simple appends/removals should use templates to keep behavior consistent and testable.

7. Background Jobs & Recurring Work

  • Queue: Use Solid Queue (Rails 8 default) with dedicated workers or Puma plugin. Mark jobs self.queue_adapter = :solid_queue.
  • Tenancy handoff: Jobs must serialize Current.account and raise if missing. Background code should never query without scoping.
  • Recurring tasks: Define in config/recurring.yml (or similar) for notification bundling, entropy cleanup, cache purges, etc. Keep schedules documented and idempotent.
  • Job testing:
    test "auto archive respects tenant" do
      Current.with_account(accounts(:alpha)) do
        assert_enqueued_with(job: AutoArchiveJob) { cards(:alpha_task).auto_archive_later }
      end
      perform_enqueued_jobs
      assert cards(:alpha_task).reload.archived?
    end

8. Real-Time Updates & Notifications

  • Turbo Streams: Broadcast model changes to tenant-specific streams (broadcast_replace_to [Current.account, resource]). Use templates for anything non-trivial.
  • ActionCable: Identify connections by tenant + user. Provide remote_connections.where(current_user: user).disconnect helpers for session revocation.
  • Web Push & Email: Store push subscriptions with account_id, user_id, and user agent string. Enforce VAPID keys via env/credentials. Queue outbound mail/push through jobs that respect involvement settings.
  • Bundling: Batch notifications (e.g., digest emails) via recurring jobs to avoid spam. Only include verified and opted-in users.

9. Search & Attachments

  • Search: Choose backend (SQLite FTS, Postgres tsvector, MySQL fulltext). Maintain shadow tables or triggers for denormalized search data. Provide sanitized query builders (search_queries.sanitize_terms).
  • Attachments: Active Storage with tenant scoping (store account_id on blobs/attachments). Process thumbnails/previews asynchronously. Use S3-compatible storage in production; local disk in dev/test.
  • Metadata: Keep message/card plain_text_body helpers for indexing, mention detection, and previews.

10. Database Evolution & Integrity

  • Always generate migrations with Rails generators. Default new tables to UUIDv7 primary keys via create_table :foo, id: :uuid, default: -> { "uuid_to_bin(uuid_generate_v7())" } (adjust per adapter). Use reversible migrations and two-phase deployments for dangerous changes.
  • Include account_id and composite indexes (index [:account_id, :created_at]) on all tenant data.
  • Use database constraints (null: false, foreign_key: true, unique indexes) plus model validations.
  • Run bin/rails db:migrate, db:test:prepare, and update fixtures/factories after schema changes.
  • Encrypt sensitive columns with encrypts and manage keys via bin/rails db:encryption:init.

11. Security & Compliance

  • SSRF: Funnel any outbound HTTP through a guard that validates IP ranges and hostnames.
  • CSP/Permissions Policy: Maintain strict CSP per environment; document any relaxations. Deny by default.
  • Credentials: Use bin/rails credentials:edit or .env templates checked into repo (without values). Never commit secrets.
  • Rate limiting: Implement throttles for login/magic links/join codes/webhooks.
  • Auditing: Log key actions with request IDs, user IDs, and account IDs for traceability.

12. Testing Expectations

  • Unit/model tests: Cover scopes, callbacks, validations, encryption, tenant scoping.
  • Controller/request tests: Assert auth, tenancy enforcement, Turbo Stream responses, JSON payloads.
  • System tests: Use Capybara + Selenium (or Cuprite) for primary flows (sign-in, board creation, messaging).
  • Job tests: Use ActiveJob::TestHelper with tenant context.
  • Security tests: Reproduce prior CVEs (SSRF, unverified emails) to prevent regressions.
  • Performance tests: Where needed, use integration tests to ensure caching/etag behavior.

Example controller test:

test "non-admin cannot create room when restricted" do
  accounts(:alpha).settings.restrict_room_creation_to_admins = true
  accounts(:alpha).save!

  sign_in users(:member)

  assert_no_difference -> { Room.count } do
    post account_rooms_path(account_slug: accounts(:alpha).slug), params: { room: { name: "Ops" } }
  end

  assert_response :forbidden
end

13. Development Workflow

  • Run bin/setupbin/dev (or bin/rails server) before coding.
  • Use generators for everything (bin/rails g model, controller, stimulus controller, etc.).
  • Prototype data flows in bin/rails console.
  • Tail logs with tail -f log/development.log.
  • Run bin/rails test (or bin/ci) + Brakeman + Bundler-Audit before committing.
  • Document new env vars or setup steps in README/AGENTS immediately.

Greenfield Feature Flow

  1. Re-read this guide; summarize affected sections in your worklog.
  2. Create/modify migrations for new persistence.
  3. Write/adjust models + concerns with tenant scopes.
  4. Build controllers/views with Turbo/Stimulus hooks and accessibility.
  5. Add tests (model, controller, job, system).
  6. Run linters/tests/scans.
  7. Update docs/config (README, architecture, AGENTS) if conventions changed.

Starting a New Project From Scratch

  1. rails new app_name --css=tailwind --javascript=importmap --database=sqlite3
  2. cd app_name && bin/setup
  3. bundle add foreman → create Procfile.dev (web, js, css, queue)
  4. bin/rails turbo:install stimulus:install importmap:install
  5. Create Current class, tenancy middleware, ActiveJob extensions, ActionCable config.
  6. Add baseline models (Account, Identity, User, Session) with UUID PKs.
  7. Setup Solid Queue, Solid Cache, Action Mailer previews, Hotwire scaffolding.
  8. Document dev URL, credentials, seed accounts, and testing commands.

14. Deployment & Operations

  • Prefer Kamal or similar containerized deployment. Provide config/deploy.yml with host, env vars, secrets references, and proxy SSL setup.
  • Persistence: application DB + Redis (if needed) + Active Storage bucket.
  • Background processing: either Solid Queue workers or Puma plugin with SOLID_QUEUE_IN_PUMA.
  • Observability: expose /up, Prometheus metrics (if required), structured logs with tenant/user IDs.
  • Recurring tasks: ensure cron/que scheduler runs config/recurring.yml.
  • Disaster recovery: back up DB and storage regularly; document restore procedures.

15. Implementation Playbook for Agents

  1. Scope & context: Identify the tenant-aware domain area you are touching. Note relevant sections of this guide and cite them in your status updates.
  2. Reuse building blocks: Controllers must include the right concerns; models must include existing modules. Don’t reinvent policies, scopes, or Turbo helpers.
    • When adding behavior shared by multiple controllers/models, extend the existing concern or create a new one that houses the logic and tests.
    • Document any new concern (required associations, callbacks, helper methods) and ensure callers include it explicitly.
  3. Thread tenancy everywhere: Queries must scope by Current.account, background jobs must serialize the account, and ActionCable/Turbo must namespace streams by tenant.
  4. Security-first: Reuse SSRF guards, encryption helpers, CSP settings, verified-email enforcement, and ensure secrets stay out of code.
  5. Test before shipping: Unit, controller, job, and system tests are mandatory for behavior changes. Run bin/ci (or equivalent) plus security scanners.
  6. Communicate via Turbo/Stimulus: Build UI state transitions using Turbo Streams/Frames. Keep Stimulus controllers tiny and accessible.
  7. Document changes: Update README/architecture/AGENTS when conventions or env requirements shift. Note manual verification steps in PRs.
  8. Generator-first workflow: Create new models/controllers/jobs via generators to get routes/tests/fixtures scaffolded. Remove boilerplate you don’t need afterward.
  9. Cleanup bias: Remove dead code/warts you touch (“boy scout rule”). Mention cleanups in commit notes.
  10. Peer review: Every change must be reviewed by a DHH-voice agent who enforces this guide.

Follow this playbook and every multi-tenant Rails app you build will be intentional, secure, and easy to maintain.