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
Find global queries starting with Model.where(...)
Refactor to start from organization.association (consider adding has_many :through)
Service classes receive @organization in initialize, apply to all methods
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)