๐Ÿ”’

Tenant Isolation Query Patterns โ€” Start from Organization Scope, Not Model.where

Query patterns that structurally prevent data leaks in multi-tenant Rails apps

This is a pattern that comes up repeatedly in PR reviews.

The Problematic Code

assets_with_log = Contract::Asset
  .joins(:contract)
  .where(contracts: { organization_id: @organization.id })
  .where(contract_id: contract_ids)

Starts from the Contract::Asset model class and JOINs to reach organization. Why is this dangerous?

Say contract_ids comes from external input. If another org's contract_id is mixed in? The organization_id condition is there via JOIN, but as queries grow complex, this condition can be dropped or misapplied. A new developer modifying the code might accidentally remove the .where(contracts: { organization_id: ... }) line โ€” exposing all orgs' data.

Safe Patterns โ€” 3 Options

1. Subquery approach

assets_with_log = Contract::Asset
  .where(contract_id: @organization.contracts.where(id: contract_ids).select(:id))

Safer than JOIN โ€” the subquery guarantees org scope. But it still starts from Contract::Asset, a model class. Reading the first line, you think "is this a global query?" You have to inspect the subquery to confirm org scoping.

Safety is ensured, but intent isn't visible on the code surface.

2. has_many :through (the answer)

# Add to Organization model
has_many :contract_assets, through: :contracts, source: :assets

# Usage
assets_with_log = @organization.contract_assets
  .where(contract_id: contract_ids)

Starting from @organization.contract_assets โ€” no model class in sight. First line tells you "this only touches this org's data."

Feeling uneasy about Contract::Asset.where(...) isn't irrational. Queries starting from model classes mean "filter from the entire table." organization.contract_assets means "start from a scope where only this org's assets exist." Different starting points.

3. Service class pattern

class ContractAssetExporter
  def initialize(organization)
    @organization = organization
  end
end

With @organization in initialize, every method in the class automatically operates within org scope.

Core Principle

Global queries (Model.where(...)) depend on ID correctness. Polluted IDs leak data.

Org-scoped queries (organization.association.where(...)) structurally guarantee tenant isolation. Even with polluted IDs, subqueries/associations block access to data outside the org.

"Don't trust IDs. Trust query structure."

"Do I need @organization for every query in a 3-step chain?"

Consider this code:

def export(contract_ids)
  # Step 1: org-scoped
  contracts = @organization.contracts.where(id: contract_ids)

  # Step 2: starts from model class
  assets = Contract::Asset.where(contract_id: contracts.pluck(:id))

  # Step 3: starts from model class
  logs = LeaseAccountingLog.where(asset_id: assets.pluck(:id))
end

Step 1 is safe. But steps 2 and 3? "contracts is already org-scoped, so IDs from it are safe too, right?" Yes โ€” right now.

But 6 months later someone adds a method reusing the assets variable from elsewhere. Or the contracts query logic changes and loses its scope. Steps 2 and 3 depend on step 1's result being "correct." Break that assumption and everything cascades.

Safe version:

def export(contract_ids)
  contracts = @organization.contracts.where(id: contract_ids)
  assets = @organization.contract_assets.where(contract_id: contract_ids)
  logs = @organization.lease_accounting_logs.where(asset_id: assets.select(:id))
end

Each step independently guarantees org scope. If step 1 breaks, steps 2 and 3 still can't escape the org.

"Don't trust previous step's results." Defensive coding has some overhead, but in multi-tenant apps, one data leak ends service trust.

Beyond safety, there's another reason. It declares "this class performs tenant isolation" through code. When every query starts with @organization., someone seeing the class for the first time can instantly tell "everything here operates within org scope" just by scanning it. If some queries use @organization and others use Model.where, you have to check line by line. Uniform usage is both a safety net and a rule that makes intent explicit.

Dangerous vs Safe Patterns

Pattern Code Safety
Global + JOIN Contract::Asset.joins(:contract).where(contracts: { organization_id: ... })
Subquery Contract::Asset.where(contract_id: org.contracts.select(:id))
has_many :through @organization.contract_assets.where(...)

has_many :through Setup (One Line)

# app/models/organization.rb
has_many :contract_assets, through: :contracts, source: :assets

# Usage โ€” org scope built into query origin
@organization.contract_assets.where(contract_id: ids)

Service Class Pattern

class Contract::AssetExportService
  def initialize(organization)
    @organization = organization
  end

  def assets_with_log(contract_ids)
    @organization.contract_assets
      .where(contract_id: contract_ids)
      .joins(:lease_accounting_log)
  end
end

Key Points

1

Find global queries starting with Model.where(...)

2

Refactor to start from organization.association (consider adding has_many :through)

3

Service classes receive @organization in initialize, apply to all methods

4

External input IDs must always pass through org scope via subquery

Pros

  • Even polluted IDs can't reach data outside the org โ€” structural safety
  • Code review: just check the query starting point to verify org scope
  • Scope omission impossible when adding new methods (service class + has_many through)

Cons

  • Adding has_many :through incurs migration cost in existing codebases
  • Subquery approach may have less efficient query plan than JOIN (large datasets)

Use Cases

SaaS B2B apps โ€” complete data isolation per organization (tenant) Contract management โ€” org scoping for 2+ level nested models like Contract::Asset Batch processing โ€” guaranteeing tenant boundaries when handling bulk IDs