# Supabase RLS本番設計ガイド：マルチテナントSaaSの認可をPostgreSQLに寄せる実践パターン

> Supabaseの行レベルセキュリティ(RLS)でマルチテナントSaaSの認可をゼロトラストにDB層へ寄せる本番設計ガイド。anon/authenticated/service_role、USING/WITH CHECK、tenant_id分離、(select auth.uid())のパフォーマンス最適化、pgTAPテストまで、公式準拠の再利用可能パターンを実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Supabase, PostgreSQL, RLS, アーキテクチャ設計, B2B SaaS, TypeScript
- URL: https://tomodahinata.com/blog/supabase-rls-production-multi-tenancy-patterns

## 要点

- マルチテナント認可は API の if 文では必ず漏れる。RLS は全経路に等しく効く最終防衛線で、ゼロトラストを DB に構造化する
- クライアントは anon key＋RLS、service_role はサーバー限定。RLS 有効化忘れは全公開なので CI で全テーブルを検査する
- USING は入口（読める行）、WITH CHECK は出口（残せる行）。UPDATE は両方書き、テナント境界は as restrictive で AND 固定する
- マルチテナント分離は membership テーブル＋security definer（set search_path='')ヘルパーで宣言的・即時・DRY に実現する
- (select auth.uid()) ラップ×索引×to 指定を型として書き、pgTAP で境界の漏れを CI のブロッキング条件にする

---

「認可はバックエンドのif文で守る」——マルチテナントSaaSでこれをやると、**いつか必ずテナント境界が漏れます**。新しいエンドポイントを足すたびに `where tenant_id = ?` を書き忘れる人間が出るからです。1箇所の漏れが「A社の管理画面にB社のデータが出る」という、SaaSで最も致命的な事故に直結します。

この記事は、その境界を**人間の規律ではなく、PostgreSQL（Supabase）の行レベルセキュリティ＝RLSで構造的に強制する**ための、再利用可能な本番設計パターン集です。私はアマチュア野球向けの[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)を Supabase + Expo + Next.js のモノレポで一人で作り、**69テーブル全てにRLSを有効化し、約280本のポリシー**でゼロトラストな認可をDB層に寄せて運用しています。本記事のパターンは、その実装と[Supabase公式ドキュメント](https://supabase.com/docs/guides/database/postgres/row-level-security)・[PostgreSQL公式ドキュメント](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)に忠実です。

> なお、同じプロダクトを題材に「**オフライン同時編集の整合性**」と「冪等性キー」に踏み込んだ記事が別にあります（[クライアントを信じない設計](/blog/untrusted-client-postgres-rls-offline-first)）。本記事はそこと重複せず、**RLSそのものの再利用可能なパターン**に集中します。認可の哲学は共通、扱う層が違う、という関係です。

## 0. 全体像：RLS設計で決めるべき5つのこと

RLSを本番で使うとは、実質この5つを設計することです。順番に意味があります。

| 決めること | 中心概念 | この記事の章 |
| --- | --- | --- |
| **どこで認可するか** | ゼロトラスト・DB層への集約 | §1 |
| **誰として実行されるか** | `anon` / `authenticated` / `service_role` | §2 |
| **何を許すか** | `USING` / `WITH CHECK` × 操作別ポリシー | §3 |
| **テナントをどう分離するか** | `tenant_id` 分離・membershipテーブル・`security definer` | §4・§5 |
| **速く・正しく動くか** | `(select auth.uid())` 最適化・索引・pgTAPテスト | §6・§7 |

---

## 1. なぜ認可をDBに寄せるのか：ゼロトラストの境界線

認可をどこに置くかは、**「どの層を信頼するか」**の宣言です。選択肢を正直に比較します。

| 認可の置き場所 | 強制力 | 漏れやすさ | 評価 |
| --- | --- | --- | --- |
| フロントのUI出し分け | なし（DevToolsで突破可） | 最悪 | ❌ UXの補助でしかない |
| BFF / APIのif文 | アプリ経由なら有効 | 高（書き忘れ・新規経路で漏れる） | ⚠️ 単独では脆い |
| **DBのRLS** | **常時・全経路で有効** | **低（DBが最後に拒否する）** | ✅ 最終防衛線 |

決定的な違いは**「迂回できるか」**です。UIのif文は `fetch` を直接叩けば消えます。APIのチェックは、新しいエンドポイントや管理スクリプト、将来の別クライアントが**そのチェックを通らずDBに到達**した瞬間に意味を失います。

RLSは違います。**ポリシーはテーブルに紐づき、どの経路から来たクエリにも等しく適用される**。PostgreSQL公式の言葉では、RLSを有効にすると「テーブルへの通常のアクセスはすべて行セキュリティポリシーで許可されなければならない」。アプリのコードが何行あろうと、**最後にDBが拒否権を持つ**——これがゼロトラストの実体です。

> 誤解しないでほしいのは、これは「APIのバリデーションを書くな」という話ではありません。**多層防御**です。入力検証はAPIで、認可の最終強制はDBで。RLSは「if文の代わり」ではなく「if文を書き忘れても破綻しない床」です。

---

## 2. RLSの基礎：ロールと「有効化し忘れ」の恐怖

### 2-1. Supabaseの3つのロール

Supabaseのクライアントが発行するリクエストは、JWTに応じてPostgresの**3つのロールのいずれか**として実行されます。RLSポリシーは「誰として実行されているか」で出し分けるため、まずこれを正確に把握します（[Supabase Auth](https://supabase.com/docs/guides/auth)）。

| ロール | 誰か | RLSの扱い | クライアント露出 |
| --- | --- | --- | --- |
| `anon` | 未認証（ログイン前） | **適用される** | 公開可（anon key） |
| `authenticated` | ログイン済みユーザー | **適用される** | 公開可（同上、JWTで識別） |
| `service_role` | サーバー専用の特権 | **バイパスする** | **絶対に公開不可** |

ここで**最重要の安全則**：`service_role` キーは**RLSを丸ごと無視**します。これをクライアント（モバイル・ブラウザ）に出した瞬間、RLSは存在しないも同然になります。クライアントには必ず **anon key + RLS** を使い、`service_role` は信頼できるサーバー（Edge Function / バックエンド）の中だけに閉じ込めます（詳細は§8の落とし穴で再掲）。

```ts
// ✅ クライアント側：anon key。RLSが効く前提で全データアクセスを設計する
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // 公開してよい。RLSが守る
);
```

```ts
// ⚠️ サーバー側のみ：service_role はRLSをバイパスする。環境変数はサーバー限定
// このキーがバンドルに混入したら全テナントのデータが筒抜けになる
import { createClient } from '@supabase/supabase-js';

export const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // NEXT_PUBLIC_ を絶対に付けない
  { auth: { persistSession: false } },
);
```

### 2-2. 有効化し忘れ＝全公開

新しいテーブルを作っただけでは、RLSは**有効になりません**。そして「RLS無効のテーブル」は、anon key からでも**全行が読み書き可能**です。

```sql
-- ❌ これだけでは無防備。anon からでも全行アクセスできてしまう
create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null,
  amount integer not null
);

-- ✅ 必ずテーブル作成と同じマイグレーションで有効化する
alter table public.invoices enable row level security;
```

ここに**RLSの最も怖い非対称性**があります。PostgreSQLでは、RLSを有効にしてポリシーを一つも書かない場合、「デフォルト拒否（default-deny）」となり**全行が見えなくなる**（安全側に倒れる）。一方、**RLSの有効化自体を忘れると全公開**（危険側に倒れる）。つまり**事故の方向が「全公開」**です。

> **運用上の鉄則**：「`public` スキーマの全テーブルにRLSが有効か」をCIで検査してください。Supabaseのダッシュボードは無効テーブルを警告しますが、人間の目視に頼ってはいけません。私のプロジェクトで**69テーブル全てにRLS**を徹底できたのは、この検査を自動化したからです（§7にクエリを載せます）。

---

## 3. USING と WITH CHECK と操作別ポリシー

RLSで最初に混乱するのが「`USING` と `WITH CHECK` の違い」です。ここを正確に理解すると、ポリシー設計が一気に明快になります（[PostgreSQL: ddl-rowsecurity](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。

| 句 | 何を判定するか | 効く操作 |
| --- | --- | --- |
| `USING` | **既存の行**が見える/触れるか（読み取り時のフィルタ・更新/削除の対象判定） | SELECT / UPDATE / DELETE |
| `WITH CHECK` | **書き込もうとする行（新しい値）**が許されるか | INSERT / UPDATE |

直感的に言えば、**`USING` は「入口（どの行を扱えるか）」、`WITH CHECK` は「出口（どんな行を残せるか）」**です。

操作ごとに必要な句を表にすると、設計の指針になります。

| 操作 | `USING` | `WITH CHECK` | 補足 |
| --- | --- | --- | --- |
| `SELECT` | ✅ 必須 | — | 見える行を絞る |
| `INSERT` | — | ✅ 必須 | 不正な行の挿入を拒否 |
| `UPDATE` | ✅（対象行） | ✅（更新後の値） | **両方**書くのが安全 |
| `DELETE` | ✅ 必須 | — | 消せる行を絞る |

`UPDATE` が両方を要するのが要点です。`USING` だけだと「自分のテナントの行を、**他テナントのtenant_idに書き換えて逃がす**」攻撃を防げません。`WITH CHECK` で更新後の値も縛ります。

```sql
-- 自分のレコードだけ操作できる、最小の4ポリシー（公式の基本形）
-- 参照: https://supabase.com/docs/guides/database/postgres/row-level-security

create policy "select own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "insert own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "update own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )        -- 対象は自分の行だけ
with check ( (select auth.uid()) = user_id );    -- 更新後も自分の行のまま

create policy "delete own profile"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
```

> **なぜ `for all` 一本にまとめないのか**：`for all` でも書けますが、操作別に分けると「読みは緩く、書きは厳しく」のような**非対称な認可**を表現でき、テストもしやすくなります（SRP的に「一つのポリシーは一つの責務」）。実運用では操作別を推奨します。なお公式の注意として、**UPDATEを行うには対応するSELECTポリシーも必要**です（更新対象行を読めないと更新できない）。

### ポリシーはOR/ANDで合成される

複数のポリシーが同じ操作に適用されると、PostgreSQLは**permissive（既定）はOR、restrictive（`as restrictive`）はAND**で合成します。これは強力な設計レバーです。

- **permissive（OR）**：「自分の行 **または** 公開フラグ付き」のように**許可を足し算**したいとき。
- **restrictive（AND）**：「**いかなる場合も**MFA(aal2)必須」のように**全ポリシーに横断する制約**を掛けたいとき。

```sql
-- restrictive: テナント分離は「絶対条件」。他のpermissiveポリシーと AND される
create policy "tenant boundary (hard constraint)"
on public.invoices as restrictive
to authenticated
using ( tenant_id = (select private.current_tenant_id()) )
with check ( tenant_id = (select private.current_tenant_id()) );
```

`as restrictive` を「テナント境界」に使うと、**後からどんなpermissiveポリシーを足しても、テナント境界だけは決して緩まない**。これがマルチテナントSaaSで効きます（`current_tenant_id()` は§5で定義）。

---

## 4. マルチテナンシー・パターン①：tenant_idによる分離

最も基本的なマルチテナント分離は、全テーブルに `tenant_id`（`org_id`）を持たせ、**「自分の所属テナントの行しか触れない」**をRLSで強制することです。

「自分のテナント」をどう知るか。素朴には「ユーザー→テナントの対応表をJOIN」ですが、それを毎ポリシーに書くと**重複（DRY違反）**かつ**遅い**。Supabase公式が推奨するのは、JWTの `app_metadata` か、後述の `security definer` ヘルパーで一発で引く形です。

`app_metadata` は**ユーザー自身が書き換えられない**領域なので、認可情報の格納に適します（`user_metadata` はユーザーが改変できるため認可に使ってはいけません）。

```sql
-- JWTの app_metadata から tenant_id を取る最速パターン
-- 参照: https://supabase.com/docs/guides/database/postgres/row-level-security#helper-functions
create policy "isolate by tenant via JWT"
on public.invoices for select
to authenticated
using (
  tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid
);
```

> **JWTパターンの落とし穴**：JWTは**即時には更新されません**。ユーザーをテナントから外しても、その変更は**JWTがリフレッシュされるまで `auth.jwt()` に反映されない**（公式明記）。「権限剥奪が即時に効いてほしい」要件では、JWTではなく**DBを参照するヘルパー関数**（§5）を使ってください。私のプロジェクトは「権限変更の即時反映」を要件にしたため、後者を主軸にしました。

---

## 5. マルチテナンシー・パターン②：membershipテーブル + security definerヘルパー

実運用のSaaSは「1ユーザーが複数テナントに所属」「ロールごとに権限が違う」が普通です。これはJWTだけでは表現しきれず、**所属を表すjoinテーブル（membership）**が要ります。

```sql
-- ユーザーとテナントの多対多。ロールもここに持つ
create table public.memberships (
  user_id   uuid not null references auth.users (id) on delete cascade,
  tenant_id uuid not null references public.tenants (id) on delete cascade,
  role      text not null check (role in ('owner', 'member', 'viewer')),
  primary key (user_id, tenant_id)
);
alter table public.memberships enable row level security;
```

### なぜ security definer ヘルパーが要るのか

ポリシーの中で `memberships` を直接JOINすると、**そのJOIN自体にも `memberships` のRLSが効いて無限再帰や複雑化を招きます**。これを断ち切るのが **`security definer` 関数**です。これは**定義者（＝管理者）の権限で実行され、RLSをバイパス**してメンバーシップを引けます。知識を一箇所に集約できる（DRY）のも利点です。

公式が強く推奨する作法が2つあります。

1. **公開スキーマに置かない**。`private` スキーマに置き、Exposed schemas に含めない（RPC経由で外から叩かれないように）。
2. **`set search_path = ''` を必ず付ける**。検索パス経由の関数すり替え攻撃を防ぐため、全オブジェクトをスキーマ修飾で書く。

```sql
-- private スキーマ（API非公開）に認可ヘルパーを集約する
create schema if not exists private;

-- 「このユーザーは、このテナントで指定ロール以上を持つか」
create or replace function private.has_tenant_role(
  p_tenant_id uuid,
  p_min_role  text
)
returns boolean
language sql
stable
security definer        -- ★定義者権限で実行＝memberships のRLSをバイパス
set search_path = ''    -- ★必須：search_path 経由の攻撃を封じる
as $$
  select exists (
    select 1
    from public.memberships m
    where m.user_id   = (select auth.uid())
      and m.tenant_id = p_tenant_id
      and case p_min_role
            when 'viewer' then m.role in ('viewer', 'member', 'owner')
            when 'member' then m.role in ('member', 'owner')
            when 'owner'  then m.role = 'owner'
            else false
          end
  );
$$;

-- よく使う「現在のテナント（単一所属前提のショートカット）」
create or replace function private.current_tenant_id()
returns uuid
language sql
stable
security definer
set search_path = ''
as $$
  select m.tenant_id
  from public.memberships m
  where m.user_id = (select auth.uid())
  limit 1;
$$;
```

これでポリシーは**宣言的かつ高速**になります。

```sql
-- 読み取り：所属テナントなら誰でも（viewer以上）
create policy "members can read invoices"
on public.invoices for select
to authenticated
using ( (select private.has_tenant_role(tenant_id, 'viewer')) );

-- 作成：member 以上、かつ自テナントの行に限る
create policy "members can create invoices"
on public.invoices for insert
to authenticated
with check ( (select private.has_tenant_role(tenant_id, 'member')) );

-- 削除：owner だけ
create policy "owners can delete invoices"
on public.invoices for delete
to authenticated
using ( (select private.has_tenant_role(tenant_id, 'owner')) );
```

> `security definer` 関数の中で**さらに `(select auth.uid())` でラップ**しているのは、§6のパフォーマンス最適化（initPlanキャッシュ）を関数内部でも効かせるためです。公式ベンチでは security definer 関数をポリシー内で `(select ...)` ラップすると **178,000ms → 12ms（99.993%改善）** という劇的な差が出ています。

---

## 6. ロール/権限パターン：owner / member / viewer

§5のヘルパーを土台に、典型的な3ロール（owner/member/viewer）の権限マトリクスを設計します。**権限は「操作×ロール」の表で定義し、それをそのままポリシーに落とす**のが、漏れと過剰権限を防ぐ最短路です。

| 操作 | viewer | member | owner | ポリシーの条件 |
| --- | --- | --- | --- | --- |
| 請求書を見る (SELECT) | ✅ | ✅ | ✅ | `has_tenant_role(tenant_id, 'viewer')` |
| 請求書を作る (INSERT) | ❌ | ✅ | ✅ | `has_tenant_role(tenant_id, 'member')` |
| 請求書を編集 (UPDATE) | ❌ | ✅ | ✅ | `has_tenant_role(tenant_id, 'member')` |
| 請求書を削除 (DELETE) | ❌ | ❌ | ✅ | `has_tenant_role(tenant_id, 'owner')` |
| メンバー招待 | ❌ | ❌ | ✅ | `has_tenant_role(tenant_id, 'owner')` |

この表が**仕様であり、テストケースであり、ポリシー定義**になります（§8でこの表をそのままpgTAPテストに変換します）。表を更新したらポリシーとテストの両方を更新する——これがロール設計を腐らせないコツです。

```sql
-- UPDATE は member 以上。USING(対象行) と WITH CHECK(更新後) の両方を縛る
create policy "members can update invoices"
on public.invoices for update
to authenticated
using ( (select private.has_tenant_role(tenant_id, 'member')) )
with check ( (select private.has_tenant_role(tenant_id, 'member')) );
```

---

## 7. パフォーマンス：RLSは「書き方」で100倍変わる

RLSは便利な反面、**書き方を誤るとテーブルスキャンのたびに関数が行ごとに走り**、致命的に遅くなります。Supabase公式の[パフォーマンス節](https://supabase.com/docs/guides/database/postgres/row-level-security#performance)には、ベンチ付きの最適化が並んでいます。実務で効く順に。

### 7-1. `auth.uid()` を `(select auth.uid())` でラップする（最重要）

`auth.uid()` を裸で書くと**行ごとに評価**されます。`(select ...)` で包むと、Postgresオプティマイザが **initPlan** を立ててステートメント単位で**1回だけ評価しキャッシュ**します。

```sql
-- ❌ Before: 行ごとに auth.uid() が走る
using ( auth.uid() = user_id );

-- ✅ After: initPlan キャッシュ。179ms → 9ms（公式ベンチで約95%改善）
using ( (select auth.uid()) = user_id );
```

これは `auth.uid()` / `auth.jwt()` / `security definer` 関数すべてに効きます。**RLSを書くときの第一の型として身体に入れてください**。

### 7-2. ポリシーで使う列に索引を張る

`tenant_id` や `user_id` のように**ポリシーの条件に出る列**は、主キーでない限り索引が要ります。

```sql
-- ポリシーが tenant_id で絞るなら、その列の索引は必須
create index invoices_tenant_id_idx
on public.invoices using btree (tenant_id);
-- 公式ベンチ: 171ms → <0.1ms（約99.94%改善）
```

### 7-3. クエリ側にも明示的なフィルタを書く

RLSの暗黙WHEREに頼り切らず、**アプリのクエリにも同じ条件**を書くと、Postgresがより良い実行計画を立てます。

```ts
// ✅ RLSが守るのは前提。その上でクエリにも tenant_id を明示する
const { data } = await supabase
  .from('invoices')
  .select('*')
  .eq('tenant_id', tenantId); // 公式ベンチ: 171ms → 9ms
```

### 7-4. ポリシー内のJOINを避け、IN/ANYに書き換える

「source表→target表」をJOINするより、**必要なidの集合を先に引いて `in` で当てる**ほうが速い。

```sql
-- ❌ 遅い: auth.uid() を team_user に JOIN して当てにいく
using (
  (select auth.uid()) in (
    select user_id from team_user where team_user.team_id = team_id
  )
);

-- ✅ 速い: 自分の所属 team_id 集合を引いて in で当てる
using (
  team_id in (
    select team_id from team_user where user_id = (select auth.uid())
  )
);
-- 公式ベンチ: 9,000ms → 20ms（約99.78%改善）
```

### 7-5. `to <role>` を必ず指定する

ロールを省くと、**そのポリシーは全ロールで評価**されます。`to authenticated` と書けば、anonリクエストはポリシー評価すら走りません。

```sql
-- ❌ ロール未指定: anon でも policy が評価される
create policy "p" on rls_test using ( auth.uid() = user_id );

-- ✅ to authenticated: anon は即座にスキップ。170ms → <0.1ms
create policy "p" on rls_test
to authenticated
using ( (select auth.uid()) = user_id );
```

> これらは**掛け算で効きます**。「`(select ...)` ラップ × 索引 × `to` 指定」を最初から型として書けば、RLSが原因で遅いという事態はほぼ起きません。逆に、後から効かせるのは（既存ポリシーの書き換え＋索引追加＋計測で）地味に手間です。**最初の型が全て**です。

---

## 8. テスト：RLSを「検証ゲート」にする（pgTAP）

RLSは認可の最終防衛線です。**最終防衛線がテストされていないのは論外**。Supabaseは**pgTAP**（PostgreSQL用のテストフレームワーク）を公式サポートしており、CIで「このロールはこの行を見える/見えない」を機械的に検証できます。§6の権限マトリクスを、そのままテストに落とします。

```sql
-- supabase/tests/rls_invoices.test.sql
begin;
select plan(4);

-- ロールと「現在のユーザー」を擬似的に切り替えるヘルパー（Supabase慣例）
create or replace function tests.authenticate_as(p_user uuid)
returns void language sql security definer set search_path = '' as $$
  select set_config('role', 'authenticated', true),
         set_config('request.jwt.claims',
                    json_build_object('sub', p_user::text)::text, true);
$$;

-- 前提データ: tenant_a に viewer の user_v、member の user_m
-- （省略: insert ...）

-- ① viewer は自テナントの請求書を「見える」
select tests.authenticate_as('00000000-0000-0000-0000-0000000000v1');
select isnt_empty(
  $$ select 1 from public.invoices where tenant_id = '...tenant_a...' $$,
  'viewer can read own-tenant invoices'
);

-- ② viewer は INSERT できない（member 未満）
select tests.authenticate_as('00000000-0000-0000-0000-0000000000v1');
select throws_ok(
  $$ insert into public.invoices(tenant_id, amount) values ('...tenant_a...', 100) $$,
  '42501',  -- insufficient_privilege
  null,
  'viewer cannot insert'
);

-- ③ 他テナントの請求書は「1行も見えない」(テナント境界)
select tests.authenticate_as('00000000-0000-0000-0000-0000000000v1');
select is_empty(
  $$ select 1 from public.invoices where tenant_id = '...tenant_b...' $$,
  'cannot read other-tenant invoices (isolation holds)'
);

-- ④ member は INSERT できる
select tests.authenticate_as('00000000-0000-0000-0000-0000000000m1');
select lives_ok(
  $$ insert into public.invoices(tenant_id, amount) values ('...tenant_a...', 200) $$,
  'member can insert into own tenant'
);

select * from finish();
rollback;
```

```bash
# CIで実行する検証ゲート（Supabase CLI）
supabase test db
```

これを**マージのブロッキング条件**にすれば、「ポリシーをうっかり緩めるPR」がmainに入りません。私のプロジェクトで**約280本のポリシー**を安心して足し続けられたのは、テナント境界テスト（上記③に相当）を全主要テーブルに張ったからです。

さらに、**RLS有効化忘れ**を機械検出するクエリも検証ゲートに入れます。

```sql
-- public スキーマで RLS が無効なテーブルを検出（1行でも出たらCI失敗にする）
select c.relname as table_without_rls
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and c.relrowsecurity = false;
```

> **テスト戦略の要点**：RLSは「ポリシーが**ある**こと」より「**境界が漏れていない**こと」をテストすべきです。`is_empty(他テナントのデータ)` こそが本丸。`isnt_empty(自分のデータ)` だけ書いて満足すると、**境界の穴を見逃します**。

---

## 9. よくある落とし穴

実務でテナント漏れ・事故に直結する典型を、対策とセットで。

| 落とし穴 | 何が起きるか | 対策 |
| --- | --- | --- |
| **RLS有効化忘れ** | 新テーブルが anon から全公開 | §8の検出クエリをCIゲートに |
| **`service_role` キーのクライアント混入** | RLSが全バイパス＝全テナント筒抜け | キーはサーバー限定。`NEXT_PUBLIC_` を付けない |
| **UPDATEで `with check` 漏れ** | 自分の行を他テナントへ書き換えて流出 | UPDATEは `using` と `with check` の両方 |
| **`auth.uid()` 裸書き** | 行ごと評価で激遅 | `(select auth.uid())` でラップ |
| **`user_metadata` で認可** | ユーザーが自分で権限を改ざん | 認可は `app_metadata` か DBの membership で |
| **JWTで即時剥奪を期待** | 権限剥奪がリフレッシュまで効かない | 即時性が要るなら DB参照ヘルパーを使う |
| **null の暗黙失敗** | 未認証で `null = user_id` が常にfalse（一見安全だが意図とズレる） | 必要なら `auth.uid() is not null` を明示 |

### 落とし穴の補足：`service_role` 漏洩は「最悪の一発」

他の落とし穴がじわじわ効くのに対し、`service_role` キーのクライアント混入は**それ単体で全RLSを無効化**します。`grep -r "SERVICE_ROLE" apps/` のような検査をlint/CIに入れ、フロントのバンドルにこの文字列が含まれないことを構造的に保証してください。Supabaseの設計でも、クライアントライブラリ経由なら**ログイン中ユーザーのRLSに従う**とされていますが、キーそのものを露出させたら話は別です。**「鍵を漏らさない」が全ての前提**です。

### 落とし穴の補足：テナント境界を `as restrictive` で固める

permissiveポリシーをORで足していくうち、**どれか一つが緩くてテナント境界を貫通**する——これが一番起きやすい漏れです。§3で示した通り、**テナント境界だけは `as restrictive`** にしておくと、後から足すpermissiveポリシーが何であれ、境界は**AND合成で必ず生き残ります**。「足し算で緩む」設計を「掛け算で締まる」設計に変える、安価で強力な保険です。

---

## 10. 横断的関心事：可観測性とテーブル所有者バイパス

最後に、本番で効く2点を補足します。

- **テーブル所有者のバイパス**：PostgreSQLでは**テーブル所有者とBYPASSRLS属性ロール、superuserはRLSを通常バイパス**します。マイグレーションを流すロールが所有者の場合、**所有者自身にもRLSを強制したい**なら `alter table ... force row level security;` を使います。Supabaseの通常運用ではアプリは `authenticated`/`anon` として動くため大半は問題になりませんが、所有者ロールでバッチを流す設計では意識が必要です。
- **可観測性**：RLSで拒否されたアクセスは「0行」や `42501`（insufficient_privilege）として現れます。アプリ側で**「想定外の空結果/権限エラー」をログ・監視に乗せる**と、ポリシーのバグやテナント境界の異常を早期に検知できます。RLSは静かに拒否するので、**沈黙を観測可能にする**のが運用の作法です。

---

## まとめ：RLSは「書き忘れても破綻しない床」を作る技術

マルチテナントSaaSの認可は、人間の規律で守るには脆すぎます。RLSは、その境界を**PostgreSQLに構造として埋め込み、どの経路から来ても最後にDBが拒否する**——ゼロトラストの実装です。要点を5行で。

1. **認可はDBに寄せる**。UI/APIのif文は迂回される。RLSは全経路に等しく効く最終防衛線。
2. **ロールを正しく使う**。クライアントは `anon`+RLS、`service_role` はサーバー限定（漏洩＝全公開）。
3. **`USING`=入口、`WITH CHECK`=出口**。UPDATEは両方。テナント境界は `as restrictive` でAND固定。
4. **マルチテナントは membership + `security definer`（`set search_path = ''`）**で宣言的・即時・DRYに。
5. **`(select auth.uid())` ラップ × 索引 × `to`指定** を型にし、**pgTAPで境界の漏れをCIゲート**にする。

私はこの設計で、**69テーブル全てにRLS・約280ポリシー**を一人で構築・運用しています（題材は[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)）。同じプロダクトの「オフライン同時編集の整合性」側の設計は[こちらの記事](/blog/untrusted-client-postgres-rls-offline-first)に書きました。

「一人 × 生成AI（Claude Code）で、速く・安く・安全に」——Supabase / PostgreSQL を使ったマルチテナントSaaSの認可設計・RLS監査・パフォーマンス改善のご相談は、[お問い合わせ](/contact)からどうぞ。
