🔒

テナント分離クエリパターン — Model.whereではなく組織スコープから始める

マルチテナントRailsアプリでデータ漏洩を構造的に防ぐクエリ記述法

PRレビューで繰り返し指摘するパターンだ。

問題のあるコード

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

Contract::Assetというモデルクラスから始めてJOINでorganizationを辿る。なぜ危険か?

contract_idsが外部入力から来たとする。他組織のcontract_idが混ざっていたら? organization_id条件はJOINで付いているが、クエリが複雑になるほどこの条件が抜けたり誤って適用される可能性が高まる。新しい開発者がコードを修正する時、.where(contracts: { organization_id: ... })の行をうっかり外せば全組織のデータが露出する。

安全なパターン — 3つの選択肢

1. サブクエリ方式

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

JOINよりは安全だ。サブクエリが組織範囲を保証するから。ただ依然としてContract::Assetというモデルクラスから始まる。コードを読む人は最初の行を見て「グローバルクエリでは?」と疑う。サブクエリの中身を確認してやっと組織スコープが掛かっていると分かる。

安全性は確保されるが、意図がコード表面に現れないという問題がある。

2. has_many :through方式(正解)

# Organizationモデルに追加
has_many :contract_assets, through: :contracts, source: :assets

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

@organization.contract_assetsから始めればモデルクラスが一切出ない。最初の行だけで「この組織のデータしか扱わない」と分かる。

Contract::Asset.where(...)に不安を感じるのは非合理的な感覚ではない。モデルクラスから始まるクエリは「テーブル全体からフィルタリング」を意味する。organization.contract_assetsは「この組織のassetだけが存在するスコープ」から始まる。出発点が違う。

3. サービスクラスパターン

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

initialize@organizationを受ければ、クラス内の全メソッドが自動的に組織範囲内で動作する。

核心原則

グローバルクエリ(Model.where(...))はIDの正確性に依存する。IDが汚染されるとデータが漏れる。

組織スコープクエリ(organization.association.where(...))はクエリの構造自体がテナント分離を保証する。IDが汚染されてもサブクエリ/associationが組織外のデータへの到達を遮断する。

「IDを信頼するな。クエリ構造を信頼しろ。」

「3段クエリでも毎回@organizationから始めるべき?」

こんなコードを考えてみよう。

def export(contract_ids)
  # 第1段:組織スコープ
  contracts = @organization.contracts.where(id: contract_ids)

  # 第2段:モデルクラスから開始
  assets = Contract::Asset.where(contract_id: contracts.pluck(:id))

  # 第3段:モデルクラスから開始
  logs = LeaseAccountingLog.where(asset_id: assets.pluck(:id))
end

第1段は組織スコープで安全だ。第2段、第3段は?「contractsが既に組織スコープだからそこから出たIDも安全では?」正しい。今は安全だ。

ただ半年後に誰かがこのクラスにメソッドを追加してassets変数を別の場所でも使い始めたら?contractsクエリロジックが変わってスコープが外れたら?第2段・第3段は第1段の結果が「正しい」という前提に依存している。この前提が崩れると連鎖的に全て崩れる。

安全なバージョン:

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

各段階が独立して組織スコープを保証する。第1段が壊れても第2段・第3段はそれぞれ組織外に出ない。

「前段の結果を信頼するな。」 防衛的に書くとオーバーヘッドが多少あるかもしれないが、マルチテナントでデータ漏洩1回でサービスの信頼が終わる。

安全性以外にもう1つ理由がある。「このクラスはテナント分離をしている」をコードで宣言する効果だ。全クエリが@organization.で始まれば、コードを初めて見る人がクラスを流し読みするだけで「全て組織スコープ内で動作している」と即座に把握できる。一部だけ@organizationで残りがModel.whereだと「これはスコープが掛かっているのか?あれは?」を一行ずつ確認する必要がある。統一的に書くことは安全装置であると同時に意図を明示するルールだ。

危険なパターン vs 安全なパターン

パターン コード 安全性
グローバル + JOIN Contract::Asset.joins(:contract).where(contracts: { organization_id: ... })
サブクエリ Contract::Asset.where(contract_id: org.contracts.select(:id))
has_many :through @organization.contract_assets.where(...)

has_many :through設定(1行)

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

# 使用 — 組織スコープがクエリ起点に内蔵
@organization.contract_assets.where(contract_id: ids)

サービスクラスパターン

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

レビューチェックリスト

  • SomeModel.where(...)で始まるクエリ → 組織スコープ漏れの疑い
  • .joins(:parent).where(parents: { organization_id: ... }) → JOINベーステナントフィルタリング → サブクエリまたはthroughに転換
  • @organization.association.where(...) → クエリ起点が組織スコープ → OK
  • サービスクラスのinitialize(organization) → 全メソッド自動スコープ → OK

キーポイント

1

Model.where(...)で始まるグローバルクエリを見つける

2

organization.associationから始めるよう変更(has_many :through追加を検討)

3

サービスクラスはinitializeで@organizationを受け取り全メソッドに適用

4

外部入力IDは必ずサブクエリで組織範囲を通す

メリット

  • IDが汚染されても組織外データに到達不可 — 構造的安全性
  • コードレビューで「組織スコープが掛かっているか」を起点だけ見て判断可能
  • 新メソッド追加時のスコープ漏れ可能性を根本から遮断

デメリット

  • has_many :through追加が既存コードベースでマイグレーションコスト発生
  • サブクエリ方式はJOINよりクエリプランが非効率な場合がある(大量データ)

ユースケース

SaaS B2Bアプリ — 組織(テナント)別データ完全分離 契約管理システム — Contract::Assetのような2段階以上ネストモデルの組織スコープ 一括処理サービス — 大量IDを受けて処理する時のテナント境界保証