PostgreSQL Row-Level Security で実装する SaaS マルチテナント設計 2026 | GH Media
URLがコピーされました

PostgreSQL Row-Level Security で実装する SaaS マルチテナント設計 2026

URLがコピーされました
PostgreSQL Row-Level Security で実装する SaaS マルチテナント設計 2026

「最初は POC だから単純に WHERE 句で絞ればいい」——そう言ってリリースした SaaS が、3 か月後の機能追加で他テナントのデータが見えてしまう、という事故は本当によく起きます。マルチテナント設計の脆さは、実装を進めれば進めるほど露呈する負債です。

PostgreSQL の Row-Level Security(RLS) は、この問題を DB 側で強制的に解決する仕組みです。アプリの WHERE 句を 1 か所書き忘れても、DB が自動でテナント条件を付与してくれる——本記事では SaaS 受託で RLS を使うときの設計パターンと落とし穴を整理します。

Pool-Model + 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 をマルチテナント化したい」といったご相談は、ぜひ お問い合わせ からお気軽にどうぞ。

URLがコピーされました

グリームハブ株式会社は、変化の激しい時代において、アイデアを形にし、人がもっと自由に、もっと創造的に生きられる世界を目指しています。

記事を書いた人

鈴木 翔

鈴木 翔

技術の可能性に魅了され、学生時代からプログラミングとデジタルアートの分野に深い関心を持つ

関連記事