SaaS開発、特に「SaaSマルチテナンシー」アーキテクチャを採用している現場で、最も背筋が凍る瞬間とは何でしょうか?それは、深夜に届く「A社の管理画面にB社の売上データが表示されている」という報告でしょう。開発者がORMのクエリビルダで .where(tenantId: ...) をたった一箇所書き忘れただけで、ビジネスの信頼は崩壊します。
AWS RDSやAurora PostgreSQLを活用して数万テナントを捌く私のプロジェクトでも、かつてはアプリケーションロジックによるフィルタリングに依存していました。しかし、チームの拡大とともにレビュー漏れのリスクが高まり、ついにデータベース層での強制的な「データ分離」へと舵を切りました。本記事では、アプリ層のミスを完全に無効化する、PostgreSQL RLS(Row Level Security)の実践的な導入ガイドを、失敗談を交えて共有します。
共有データベース・共有スキーマの限界とリスク
多くのB2B SaaSでは、コストと管理の容易さから「Shared Database, Shared Schema(共有データベース、共有スキーマ)」戦略を採用します。全テナントのデータが巨大な単一テーブル(例:orders, users)に格納され、tenant_id カラムで区別される構成です。
このアーキテクチャ下で、開発者は常に以下の呪文を唱え続ける必要があります。
開発者Aが新しい集計バッチを作成。複雑なJOINを含むSQLを書く際、サブクエリの中で
WHERE tenant_id = ? を漏らしてしまう。結果、テナントXのレポートに全テナントの合計値が出力される。
私たちは当初、Hibernateの @Filter や TypeORMの Global Scope 機能を使ってこれを防ごうとしました。しかし、これらの「データベースセキュリティ」対策は、あくまでORM経由のアクセスにしか適用されません。生のSQLを実行するバッチ処理や、運用担当者が行うデータパッチ作業、あるいはマイグレーションスクリプトにおいては無力でした。つまり、アプリケーション層での防御壁は「穴だらけのチーズ」だったのです。
失敗談:VIEWによる分離の挫折
RLS導入前、私たちは「テナントごとにVIEWを作成する」というアプローチを試みました。各テーブルに対して create view tenant_a_orders as select * from orders where tenant_id = 'A' のようなビューを動的に生成しようとしたのです。
しかし、これはすぐに破綻しました。テナント数が1,000を超えたあたりで、DDLの管理コストが爆発的に増加し、スキーマ変更(マイグレーション)にかかる時間が許容範囲を超えました。また、PostgreSQLのクエリプランナが大量のVIEW定義に対して最適化しきれず、パフォーマンス劣化も招きました。動的なオブジェクト生成に頼るアプローチは、スケールするSaaSには不向きだったのです。
解決策:Row Level Security (RLS) の実装
ここで登場するのが、PostgreSQL 9.5以降で標準搭載されている「Row Level Security」です。これは、SQLの実行ユーザーやセッション変数に基づいて、データベースエンジン自体が可視範囲を制御する機能です。たとえアプリが SELECT * FROM orders と書いても、DB側が勝手に WHERE tenant_id = 'current_user' を付与して実行するような挙動を実現します。
以下は、マルチテナントSaaS向けに最適化したRLS設定の全容です。
-- 1. RLSを有効化する
-- テーブルの所有者(superuserなど)にもポリシーを強制する設定
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- 2. ポリシーの作成
-- app.current_tenant というセッション変数が、行の tenant_id と一致する場合のみアクセス許可
CREATE POLICY tenant_isolation_policy ON orders
FOR ALL
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- 3. アプリケーション側(トランザクション開始直後の処理)
-- 疑似コード:コネクション取得直後に必ず実行する
BEGIN;
-- 非常に重要:SET LOCALを使うことで、トランザクション終了時に変数がリセットされる
SET LOCAL app.current_tenant = '550e8400-e29b-41d4-a716-446655440000';
-- アプリはテナントIDを意識せずにクエリを投げられる
SELECT * FROM orders; -- 自動的に WHERE tenant_id = '...' が適用される
COMMIT;
上記のコードで最も重要なのは current_setting('app.current_tenant') の利用と、アプリケーション側での SET LOCAL の徹底です。これにより、同一の物理コネクションを使い回すコネクションプーリング環境下でも、トランザクションごとに確実にテナントコンテキストを切り替えることが可能になります。
パフォーマンスへの影響と検証
「DBレベルで毎回チェックが入ると遅くなるのではないか?」という懸念はもっともです。以下は、1000万レコードを持つテーブル(AWS RDS db.r6g.large)に対して、アプリ側フィルタリングとRLS適用後のパフォーマンスを比較したベンチマーク結果です。
| 検証シナリオ | アプリ側フィルタ (ms) | RLS適用 (ms) | オーバーヘッド |
|---|---|---|---|
| 単純SELECT (100件) | 12 ms | 12.5 ms | +4% |
| 複雑な集計 (JOINあり) | 145 ms | 148 ms | +2% |
| 大量INSERT (1000件) | 85 ms | 89 ms | +4.7% |
結果として、オーバーヘッドは概ね5%未満に収まりました。これは、tenant_id カラムに対して適切にインデックス(B-Tree)が貼られていれば、RLSの条件判定もインデックススキャンに含まれるためです。セキュリティリスクの低減効果を考えれば、この程度の遅延は無視できるレベルと言えます。むしろ、開発者が手動で書く複雑なWHERE句よりも、PostgreSQL内部で最適化されたポリシーの方が安定した実行計画を生み出すケースすらありました。
導入時の落とし穴:コネクションプーリングと特権ユーザー
RLSは強力ですが、運用環境(特にPgBouncerなどのプーラーを使用する場合)では致命的な落とし穴があります。
SET LOCAL ではなく通常の SET を使い、かつコネクションプールが「Session Mode」で動作している場合、前のトランザクションで設定された app.current_tenant が、次の無関係なリクエストに残存する可能性があります。
これを防ぐためには、以下の対策が必須です。
- Transaction Poolingの利用: PgBouncerを使用する場合はTransaction Modeを推奨します。
- 初期化フックの実装: アプリケーション側でコネクションを取得した際、必ず変数をリセットするか、新しい値を上書きするロジックをミドルウェア層に埋め込むこと。
- BYPASSRLS権限の管理:
superuserやBYPASSRLS属性を持つユーザーはRLSを無視します。アプリケーションが接続するDBユーザーには、絶対にこの権限を与えないでください。
また、バックアップとリストアの際も注意が必要です。pg_dump はデフォルトでは行セキュリティを適用したまま(つまり実行ユーザーが見えるデータだけ)ダンプしようとします。完全なバックアップを取るには、特権ユーザーで行うか、明示的なオプション指定が必要です。
まとめ
SaaSマルチテナンシーにおけるデータ分離は、サービスの生命線です。アプリケーションコードの品質に依存するセキュリティ対策は、いずれ破綻します。PostgreSQLのRLS機能は、初期設定やコネクション管理に専門的な知識が必要ですが、一度構築してしまえば、極めて堅牢な「防波堤」となります。これからSaaSを設計する場合、あるいは既存のセキュリティに不安がある場合は、ぜひRLSの導入を検討してください。
Post a Comment