「最初は POC だから単純に WHERE 句で絞ればいい」——そう言ってリリースした SaaS が、3 か月後の機能追加で他テナントのデータが見えてしまう、という事故は本当によく起きます。マルチテナント設計の脆さは、実装を進めれば進めるほど露呈する負債です。
PostgreSQL の Row-Level Security(RLS) は、この問題を DB 側で強制的に解決する仕組みです。アプリの WHERE 句を 1 か所書き忘れても、DB が自動でテナント条件を付与してくれる——本記事では SaaS 受託で RLS を使うときの設計パターンと落とし穴を整理します。

マルチテナンシー 3 モデルと RLS の位置づけ
SaaS のマルチテナント設計には大きく 3 つのモデルがあります。
| モデル | 概要 | コスト | 分離強度 | 採用例 |
|---|---|---|---|---|
| Silo(DB 分離) | テナントごとに別 DB | 高 | 強 | 規制業界向け SaaS |
| Bridge(スキーマ分離) | テナントごとに別 schema | 中 | 中 | 中小 BtoB SaaS |
| Pool(共有) | 全テナントが同テーブルを共有 | 低 | アプリ依存 → RLS で強化 | 一般的な BtoB / BtoC SaaS |
スタートアップやシード期の受託案件では、コスト効率から Pool モデルが第一候補になります。ここに RLS を組み合わせるのが、2026 年現在のスタンダードです。
RLS の最小構成 — 3 ステップ
1. テナント ID 列とポリシーを設置
-- 全テーブルに tenant_id を持たせる前提
ALTER TABLE projects ADD COLUMN tenant_id uuid NOT NULL;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);
current_setting('app.tenant_id') は、セッション変数からテナント ID を取り出す PostgreSQL 標準の仕組みです。
2. アプリ側でセッション変数を必ずセット
// リクエストごとに最初のクエリで実行
await db.execute(sql`SET LOCAL app.tenant_id = ${session.tenantId}`);
SET LOCAL を使うことで、トランザクション終了後に自動でクリアされます。必ずトランザクション内で発行してください。
3. スーパーユーザー / マイグレーション用の例外を定義
postgres ロールはデフォルトで RLS をバイパスします。アプリ用ロールはバイパス権限を剥奪し、マイグレーション用ロールだけが特権を持つよう分けます。
CREATE ROLE app_user NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
Pool-Model RLS でハマる 5 つの罠
罠 1 — Connection Pooling との相性
PgBouncer の transaction モードや、Supabase / Neon のサーバレスドライバを使う場合、コネクションが使い回されるため SET LOCAL の発行漏れがあると別テナントのコンテキストで動きます。リクエスト到着時に必ずセットする middleware の整備が必須です。
罠 2 — マイグレーションでの権限ロール
CI からマイグレーションを流すときに、つい postgres ロールで接続してしまい、RLS を「実は誰も検証していなかった」状態で本番に出るケースがあります。CI のテスト DB は app_user で接続して、本番と同条件にしましょう。
罠 3 — JOIN 先テーブルの RLS 漏れ
projects だけ RLS を設定して、tasks には設定し忘れる、というパターンは頻発します。全テーブルにポリシーを定義し、CI で pg_policies ビューを SELECT して必須テーブルが揃っているか自動チェックする運用が安全です。
罠 4 — INSERT / UPDATE 時の境界
USING 句は SELECT 時の絞り込み、WITH CHECK 句は INSERT / UPDATE 時の検証です。両方書くのが鉄則です。
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
罠 5 — ORM との相性
Drizzle ORM 移行ガイド 2026 でも触れたように、軽量 ORM ほど DB 機能との親和性が高いです。Prisma の場合は raw query で SET LOCAL を発行し、Drizzle なら db.transaction 内で確実にセッション変数を流す構造が組めます。
テスト戦略 — 「他テナントが見えないこと」を CI で保証する
最重要のテストは「テナント A のセッションでテナント B のレコードを SELECT しても 0 件返る」というネガティブテストです。これを Vitest / Jest のテーブルテストで自動化し、PR ごとに実行する仕組みを整えます。
test.each(operations)("テナント越境を防ぐ: %s", async (op) => {
await setTenant(tenantA);
await op.create(); // テナント A で作成
await setTenant(tenantB);
expect(await op.findAll()).toHaveLength(0); // B からは見えない
});
このタイプのテストを「E2E 自動化基盤」と組み合わせて、API レイヤーまで貫通させるのが受託の品質保証として理想形です。
まとめ — 「アプリで頑張らない」がマルチテナント設計の鍵
マルチテナンシーの境界をアプリ側で守ろうとすると、必ず人間がミスをします。境界は DB に押し込み、アプリはセッション変数を渡すだけ——この構造に倒すことで、機能追加のスピードと安全性を両立できます。
GleamHub では、SaaS の初期設計から RLS ポリシー設計・テスト基盤整備までを伴走しています。「これからスタートアップで SaaS を作る」「既存 SaaS をマルチテナント化したい」といったご相談は、ぜひ お問い合わせ からお気軽にどうぞ。