惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
PCI Perspectives
PCI Perspectives
H
Help Net Security
爱范儿
爱范儿
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
雷峰网
雷峰网
S
Secure Thoughts
Jina AI
Jina AI
Attack and Defense Labs
Attack and Defense Labs
大猫的无限游戏
大猫的无限游戏
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
S
Security @ Cisco Blogs
阮一峰的网络日志
阮一峰的网络日志
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Recent Commits to openclaw:main
Recent Commits to openclaw:main
The Hacker News
The Hacker News
The Last Watchdog
The Last Watchdog
T
Tor Project blog
小众软件
小众软件
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 叶小钗
博客园 - 【当耐特】
G
Google Developers Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
C
Cyber Attacks, Cyber Crime and Cyber Security
C
Cisco Blogs
T
The Blog of Author Tim Ferriss
I
Intezer
S
Schneier on Security
S
Securelist
W
WeLiveSecurity
C
Cybersecurity and Infrastructure Security Agency CISA
P
Palo Alto Networks Blog
Scott Helme
Scott Helme
Project Zero
Project Zero
Google Online Security Blog
Google Online Security Blog
T
Threatpost
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Engineering at Meta
Engineering at Meta
Blog — PlanetScale
Blog — PlanetScale
V
Visual Studio Blog
Last Week in AI
Last Week in AI
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
人人都是产品经理
人人都是产品经理
Y
Y Combinator Blog
A
Arctic Wolf
GbyAI
GbyAI
H
Hackread – Cybersecurity News, Data Breaches, AI and More
L
LangChain Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Row-Level Multitenancy in Rails: Building a Bulletproof Tenant Isolation Layer from Scratch
Temitope · 2026-05-11 · via DEV Community

If you've reached for acts_as_tenant or apartment without really understanding what's happening underneath, this tutorial is the correction. We're building a row-level multitenant system from first principles — the kind you can actually reason about when something goes wrong at 2am.

By the end, you'll have:

  • Middleware that resolves the current tenant from a subdomain or JWT claim
  • A Current-based tenant context that threads safely through the request lifecycle
  • An ApplicationRecord mixin that enforces tenant scoping at the model layer
  • Postgres row-level security policies as a hard backstop
  • A TenantSafe concern for background jobs that re-establishes context correctly
  • A test helper that won't let you accidentally write cross-tenant specs

No gems. Just Rails, Postgres, and honest code.


The Mental Model

Row-level multitenancy means every tenant's data lives in the same tables, separated by a tenant_id foreign key. The application layer is responsible for filtering. The database can optionally enforce this too (and should, as defense-in-depth).

The failure modes are well-known:

  1. A developer forgets to scope a query → data leak
  2. A background job runs without tenant context → unscoped query touches all tenants
  3. An association crosses tenant boundaries silently

We'll build guardrails against all three.


Step 1: The Tenant Model and Migration

First, the tenant itself. Keep it simple — tenants own a subdomain and that's the primary resolution mechanism.

# db/migrate/20240901000001_create_tenants.rb
class CreateTenants < ActiveRecord::Migration[7.2]
  def change
    create_table :tenants do |t|
      t.string  :name,      null: false
      t.string  :subdomain, null: false
      t.string  :status,    null: false, default: "active"
      t.jsonb   :settings,  null: false, default: {}
      t.timestamps
    end

    add_index :tenants, :subdomain, unique: true
    add_index :tenants, :status
  end
end

Enter fullscreen mode Exit fullscreen mode

# db/migrate/20240901000002_create_accounts.rb
# A representative tenant-scoped resource
class CreateAccounts < ActiveRecord::Migration[7.2]
  def change
    create_table :accounts do |t|
      t.references :tenant, null: false, foreign_key: true, index: true
      t.string :name,  null: false
      t.string :email, null: false
      t.timestamps
    end

    # Composite index: tenant lookups are always tenant-first
    add_index :accounts, [:tenant_id, :email], unique: true
  end
end

Enter fullscreen mode Exit fullscreen mode

The tenant_id column goes on every tenant-scoped table. No exceptions. Make this a convention enforced in code review, or better — a custom RuboCop rule that checks migrations.


Step 2: Thread-Local Tenant Context via Current

Rails 5.2 shipped ActiveSupport::CurrentAttributes. It gives you a request-scoped (or thread-scoped) object that's automatically reset between requests. This is the right place for tenant context.

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant

  # Convenience predicate used throughout the app
  def tenant?
    tenant.present?
  end

  # Hard assertion — use in contexts where a missing tenant is a bug
  def tenant!
    tenant || raise(TenantNotSetError, "Current.tenant is not set")
  end
end

Enter fullscreen mode Exit fullscreen mode

# app/errors/tenant_not_set_error.rb
class TenantNotSetError < StandardError; end
class TenantNotFoundError < StandardError; end

Enter fullscreen mode Exit fullscreen mode

CurrentAttributes resets between requests automatically because Rails calls reset on it at the end of each request via the executor. You get thread safety for free. Don't roll your own thread-local here — this is one case where the Rails abstraction is genuinely better.


Step 3: Middleware for Tenant Resolution

Subdomain-based resolution is the most common pattern. The middleware runs before your controllers and sets Current.tenant for the entire request.

# app/middleware/tenant_resolver.rb
class TenantResolver
  EXCLUDED_SUBDOMAINS = %w[www api admin].freeze

  def initialize(app)
    @app = app
  end

  def call(env)
    request  = ActionDispatch::Request.new(env)
    subdomain = extract_subdomain(request)

    if subdomain && EXCLUDED_SUBDOMAINS.exclude?(subdomain)
      tenant = Tenant.active.find_by(subdomain: subdomain)

      unless tenant
        return [
          302,
          { "Location" => root_url(request), "Content-Type" => "text/html" },
          ["Tenant not found"]
        ]
      end

      Current.tenant = tenant
    end

    @app.call(env)
  ensure
    # CurrentAttributes resets itself, but be explicit for clarity
    Current.reset
  end

  private

  def extract_subdomain(request)
    # Handles localhost (single part) and production (multi-part) hosts
    parts = request.host.split(".")
    return nil if parts.length <= (Rails.env.production? ? 2 : 1)
    parts.first.downcase.presence
  end

  def root_url(request)
    "#{request.protocol}#{request.host_with_port}/"
  end
end

Enter fullscreen mode Exit fullscreen mode

Register it in the stack, just after session middleware so cookies are available if you need them:

# config/application.rb
config.middleware.insert_after ActionDispatch::Session::CookieStore, TenantResolver

Enter fullscreen mode Exit fullscreen mode

If you're using API mode with JWT instead of subdomains, the shape is the same — just parse the tenant claim from the Authorization header and resolve accordingly:

# Inside TenantResolver#call, for API mode
def resolve_from_jwt(request)
  token = request.headers["Authorization"]&.delete_prefix("Bearer ")
  return unless token

  payload = JwtService.decode(token)  # your existing JWT layer
  Tenant.active.find_by(id: payload["tenant_id"])
rescue JWT::DecodeError
  nil
end

Enter fullscreen mode Exit fullscreen mode


Step 4: Enforcing Tenant Scope at the Model Layer

This is the core of the system. Every model that belongs to a tenant should be impossible to query without a scope.

# app/models/concerns/tenant_scoped.rb
module TenantScoped
  extend ActiveSupport::Concern

  included do
    belongs_to :tenant

    # Default scope: every query automatically filters by current tenant
    default_scope { where(tenant: Current.tenant) if Current.tenant? }

    # Validate that the record's tenant matches the current tenant
    validates :tenant_id, presence: true
    validate  :tenant_matches_current_context, on: :create

    before_create :assign_current_tenant
  end

  class_methods do
    # Escape hatch for internal/admin queries — use sparingly and explicitly
    def unscoped_for_tenant(tenant)
      unscoped.where(tenant: tenant)
    end

    # For cross-tenant admin operations only
    def all_tenants
      raise TenantNotSetError, "Use unscoped explicitly" unless Current.tenant.nil?
      unscoped
    end
  end

  private

  def assign_current_tenant
    self.tenant ||= Current.tenant
  end

  def tenant_matches_current_context
    return unless Current.tenant?
    return if tenant_id == Current.tenant.id

    errors.add(:tenant_id, "does not match current tenant context")
  end
end

Enter fullscreen mode Exit fullscreen mode

Include it in your models:

# app/models/account.rb
class Account < ApplicationRecord
  include TenantScoped

  validates :name,  presence: true
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end

Enter fullscreen mode Exit fullscreen mode

Now Account.all automatically returns only the current tenant's accounts. Account.create!(name: "Acme") automatically assigns tenant_id. You can't accidentally cross tenant boundaries in normal operation.

A Note on default_scope

default_scope is controversial in the Rails community, and the criticism is fair: it makes behaviour non-obvious, and it can cause surprising joins. Here's the rule: use it only for tenant scoping, never for anything else (no order, no where deleted_at IS NULL). Tenant scoping is the one case where you actually want it to be invisible — the whole point is that forgetting the scope is the bug.


Step 5: Postgres Row-Level Security as a Hard Backstop

The application layer is the first line of defense. RLS is the second. Even if a bug slips through your default scope, Postgres won't return data that doesn't belong to the current tenant.

-- db/migrate/20240901000010_enable_rls_on_accounts.rb
class EnableRlsOnAccounts < ActiveRecord::Migration[7.2]
  def up
    # Create an application-level DB user that isn't a superuser
    execute <<~SQL
      ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
      ALTER TABLE accounts FORCE ROW LEVEL SECURITY;

      -- Policy: SELECT/INSERT/UPDATE/DELETE only see rows for the current tenant
      CREATE POLICY tenant_isolation ON accounts
        USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
        WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint);
    SQL
  end

  def down
    execute <<~SQL
      DROP POLICY IF EXISTS tenant_isolation ON accounts;
      ALTER TABLE accounts DISABLE ROW LEVEL SECURITY;
    SQL
  end
end

Enter fullscreen mode Exit fullscreen mode

Now wire the Postgres session variable from Rails. The cleanest place is an around_action in ApplicationController, after the middleware has already set Current.tenant:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  around_action :set_rls_tenant_context

  private

  def set_rls_tenant_context
    return yield unless Current.tenant?

    # Set the session-level variable Postgres uses in the RLS policy
    ActiveRecord::Base.connection.execute(
      ActiveRecord::Base.sanitize_sql(
        ["SELECT set_config('app.current_tenant_id', ?, false)",
         Current.tenant.id.to_s]
      )
    )
    yield
  rescue ActiveRecord::StatementInvalid => e
    # Don't let RLS config failure silently succeed
    Rails.logger.error("RLS context error: #{e.message}")
    raise
  end

Enter fullscreen mode Exit fullscreen mode

The false argument to set_config means the setting is transaction-scoped, not session-scoped. This is correct — you want each request/transaction to set it explicitly, not inherit it from a pooled connection's previous request.

Connection pool caveat: Because you're using a connection pool (via PgBouncer or Rails' own pool), you must set the Postgres variable on every transaction, not assume it persists. The around_action above handles this correctly because it runs on every request.


Step 6: Background Jobs Without Foot Guns

Background jobs are where multitenant systems most commonly break. A job enqueued in a tenant context runs later in a worker process that has no HTTP request — so Current.tenant is nil, and your default scopes stop working.

The pattern: always serialize the tenant_id with the job, and restore Current.tenant before the job body runs.

# app/jobs/concerns/tenant_aware.rb
module TenantAware
  extend ActiveSupport::Concern

  included do
    before_enqueue  :capture_tenant_context
    before_perform  :restore_tenant_context
    after_perform   :clear_tenant_context
    around_perform  :with_rls_context
  end

  private

  def capture_tenant_context
    # Store tenant_id in the job's arguments at enqueue time
    raise TenantNotSetError, "#{self.class} enqueued outside tenant context" unless Current.tenant?

    # We use a job-level instance variable; Sidekiq serializes via #serialize
    @tenant_id_for_job = Current.tenant.id
  end

  def restore_tenant_context
    tenant_id = arguments.last.is_a?(Hash) ? arguments.last[:_tenant_id] : nil
    tenant_id ||= @tenant_id_for_job

    raise TenantNotSetError, "No tenant_id found in job arguments" unless tenant_id

    Current.tenant = Tenant.find(tenant_id)
  end

  def clear_tenant_context
    Current.reset
  end

  def with_rls_context
    if Current.tenant?
      ActiveRecord::Base.connection.execute(
        ActiveRecord::Base.sanitize_sql(
          ["SELECT set_config('app.current_tenant_id', ?, false)",
           Current.tenant.id.to_s]
        )
      )
    end
    yield
  end
end

Enter fullscreen mode Exit fullscreen mode

For Sidekiq specifically, the cleanest approach is a middleware that injects and restores tenant context automatically, so you don't have to remember to include the concern on every job:

# config/initializers/sidekiq.rb

# Client middleware: inject tenant_id when the job is pushed to Redis
class SidekiqTenantClientMiddleware
  def call(_worker_class, job, _queue, _redis_pool)
    job["tenant_id"] = Current.tenant&.id
    yield
  end
end

# Server middleware: restore Current.tenant before the worker runs
class SidekiqTenantServerMiddleware
  def call(worker, job, queue)
    tenant_id = job["tenant_id"]

    if tenant_id
      Current.tenant = Tenant.find(tenant_id)

      ActiveRecord::Base.connection.execute(
        ActiveRecord::Base.sanitize_sql(
          ["SELECT set_config('app.current_tenant_id', ?, false)", tenant_id.to_s]
        )
      )
    end

    yield
  ensure
    Current.reset
  end
end

Sidekiq.configure_client do |config|
  config.client_middleware do |chain|
    chain.add SidekiqTenantClientMiddleware
  end
end

Sidekiq.configure_server do |config|
  config.client_middleware do |chain|
    chain.add SidekiqTenantClientMiddleware
  end

  config.server_middleware do |chain|
    chain.add SidekiqTenantServerMiddleware
  end
end

Enter fullscreen mode Exit fullscreen mode

Now every job automatically carries its tenant context. Workers restore it without any per-job boilerplate.


Step 7: Test Helpers That Enforce the Rules

If your test suite doesn't force tenant context, you'll write tests that pass but miss real bugs. Here's a helper module that makes tenant context explicit and prevents accidental unscoped queries in specs.

# spec/support/tenant_helpers.rb
module TenantHelpers
  # Sets Current.tenant for the duration of a block
  def with_tenant(tenant, &block)
    previous = Current.tenant
    Current.tenant = tenant
    block.call
  ensure
    Current.tenant = previous
  end

  # Creates a tenant and sets it as current for the example
  def acting_as_tenant(tenant = nil)
    tenant ||= create(:tenant)
    Current.tenant = tenant
    tenant
  end

  # Assert that a block does NOT execute any unscoped cross-tenant queries
  def expect_tenant_safe(&block)
    queries = []
    subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |_, _, _, _, payload|
      queries << payload[:sql] if payload[:sql].match?(/FROM "accounts"|FROM "orders"/)
    end

    block.call

    unscoped = queries.reject { |q| q.include?("tenant_id") }
    expect(unscoped).to be_empty,
      "Found #{unscoped.count} unscoped queries:\n#{unscoped.join("\n")}"
  ensure
    ActiveSupport::Notifications.unsubscribe(subscriber)
  end
end

RSpec.configure do |config|
  config.include TenantHelpers

  # Auto-reset Current between examples
  config.around(:each) do |example|
    Current.reset
    example.run
    Current.reset
  end
end

Enter fullscreen mode Exit fullscreen mode

Usage in specs:

# spec/models/account_spec.rb
RSpec.describe Account, type: :model do
  let(:tenant_a) { create(:tenant, subdomain: "alpha") }
  let(:tenant_b) { create(:tenant, subdomain: "beta") }

  describe "tenant isolation" do
    before do
      with_tenant(tenant_a) { create_list(:account, 3) }
      with_tenant(tenant_b) { create_list(:account, 2) }
    end

    it "only returns the current tenant's accounts" do
      with_tenant(tenant_a) do
        expect(Account.count).to eq(3)
      end
    end

    it "does not leak tenant_b records into tenant_a context" do
      with_tenant(tenant_a) do
        tenant_b_account = Account.unscoped.where(tenant: tenant_b).first
        expect { Account.find(tenant_b_account.id) }.to raise_error(ActiveRecord::RecordNotFound)
      end
    end

    it "raises when tenant context is absent" do
      Current.reset
      expect { Account.all.load }.to raise_error(TenantNotSetError)
    end
  end

  describe "cross-tenant query safety" do
    it "scopes all queries to the current tenant" do
      acting_as_tenant(tenant_a) do
        expect_tenant_safe { Account.where(name: "anything").to_a }
      end
    end
  end
end

Enter fullscreen mode Exit fullscreen mode


Step 8: The Admin Escape Hatch

You'll need internal tooling — Sidekiq Web, an admin dashboard, a Rails console task — that operates without a tenant context. Do this explicitly, never by accident.

# app/models/concerns/tenant_scoped.rb (add to class_methods block)
def for_tenant(tenant)
  unscoped.where(tenant: tenant)
end

def across_all_tenants
  # Explicitly documents intent; cannot be called by accident
  raise ArgumentError, "You must acknowledge cross-tenant access" unless block_given?
  unscoped { yield }
end

Enter fullscreen mode Exit fullscreen mode

# Usage in a Rake task or admin controller
namespace :tenants do
  desc "Backfill a field across all tenants"
  task backfill_account_status: :environment do
    Tenant.find_each do |tenant|
      Account.for_tenant(tenant).find_each do |account|
        account.update_columns(status: "active") if account.status.nil?
      end
      puts "Done: #{tenant.subdomain}"
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

Note the pattern: even in cross-tenant admin tasks, we still loop through tenants explicitly rather than doing a single Account.update_all. This keeps queries tenant-anchored, which is better for Postgres query plans on large datasets anyway.


Common Pitfalls and How to Catch Them

Joins That Escape the Scope

default_scope applies to the base model but not to eagerly-loaded associations by default. Test this explicitly:

# This is safe — tenant scoped on Account
Account.includes(:orders).where(name: "Acme")

# But what about Order? Does it have TenantScoped too?
# If yes, the included records are separately scoped. Good.
# If no, you're loading all orders for the matching accounts across all tenants. Bad.

Enter fullscreen mode Exit fullscreen mode

Every model in a has_many or belongs_to relationship should include TenantScoped if it's tenant data. Don't let associations be the escape valve.

Cached Queries in Mailers

ActionMailer runs outside a request context in production (delivered via background job). Make sure your mailers go through the same Sidekiq middleware path and have Current.tenant set before rendering:

class AccountMailer < ApplicationMailer
  def welcome(account_id)
    # Don't pass the object — pass the ID and reload in the mailer
    # This forces the query to run inside the correct tenant context
    @account = Account.find(account_id)
    mail(to: @account.email, subject: "Welcome")
  end
end

Enter fullscreen mode Exit fullscreen mode

Fixtures and Seeds With tenant_id

If you use db/seeds.rb or fixtures, they run outside tenant context. Either set Current.tenant explicitly in the seed file, or use Account.unscoped.create! with explicit tenant_id:

# db/seeds.rb
alpha = Tenant.create!(name: "Alpha Corp", subdomain: "alpha")
beta  = Tenant.create!(name: "Beta LLC",   subdomain: "beta")

Current.tenant = alpha
Account.create!(name: "Alice", email: "alice@alpha.com")

Current.tenant = beta
Account.create!(name: "Bob", email: "bob@beta.com")

Current.reset

Enter fullscreen mode Exit fullscreen mode


What You Now Have

Let's audit the threat model:

Threat Mitigation
Developer forgets to scope a query default_scope on every model via TenantScoped
Scope is bypassed via unscoped RLS policy in Postgres catches it
Background job runs without context Sidekiq middleware serializes and restores tenant_id
Cross-tenant association load Every associated model includes TenantScoped
Test suite masks real bugs expect_tenant_safe helper + Current.reset around each spec
Admin task accidentally touches all tenants Explicit for_tenant / across_all_tenants API

This isn't magic — it's conventions, enforced at multiple layers. The value is that no single layer needs to be perfect. If the application scope slips, RLS catches it. If RLS is misconfigured for a table, the model validation catches it on write. Defense-in-depth, each layer honest about what it does.


Where to Go Next

  • Schema-per-tenant: If your tenants need true schema isolation (compliance reasons, not just data volume), look at apartment or roll your own search_path switcher. The tradeoffs are well documented; schema-per-tenant is significantly more operationally complex.
  • Tenant provisioning: Automating CREATE POLICY for new tables as your schema evolves. A custom migration generator that adds the RLS boilerplate helps.
  • Query performance at scale: At 10k+ tenants, your tenant_id index cardinality affects query plans. Consider BRIN indexes for time-series tenant data and composite indexes that put tenant_id first on any multi-column lookup.
  • Audit logging: Every mutation should record tenant_id, user_id, and the previous value. PaperTrail's controller_info hook is the cleanest place to attach tenant context to the audit trail.

The system above will hold through 99% of what production throws at it. The other 1% is where you earn your scars — and now at least you'll know exactly which layer to look at first.