「認可はバックエンドのif文で守る」——マルチテナントSaaSでこれをやると、いつか必ずテナント境界が漏れます。新しいエンドポイントを足すたびに where tenant_id = ? を書き忘れる人間が出るからです。1箇所の漏れが「A社の管理画面にB社のデータが出る」という、SaaSで最も致命的な事故に直結します。
この記事は、その境界を人間の規律ではなく、PostgreSQL(Supabase)の行レベルセキュリティ=RLSで構造的に強制するための、再利用可能な本番設計パターン集です。私はアマチュア野球向けのリアルタイム試合記録アプリを Supabase + Expo + Next.js のモノレポで一人で作り、69テーブル全てにRLSを有効化し、約280本のポリシーでゼロトラストな認可をDB層に寄せて運用しています。本記事のパターンは、その実装とSupabase公式ドキュメント・PostgreSQL公式ドキュメントに忠実です。
なお、同じプロダクトを題材に「オフライン同時編集の整合性」と「冪等性キー」に踏み込んだ記事が別にあります(クライアントを信じない設計)。本記事はそこと重複せず、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)。
| ロール | 誰か | RLSの扱い | クライアント露出 |
|---|---|---|---|
anon | 未認証(ログイン前) | 適用される | 公開可(anon key) |
authenticated | ログイン済みユーザー | 適用される | 公開可(同上、JWTで識別) |
service_role | サーバー専用の特権 | バイパスする | 絶対に公開不可 |
ここで最重要の安全則:service_role キーはRLSを丸ごと無視します。これをクライアント(モバイル・ブラウザ)に出した瞬間、RLSは存在しないも同然になります。クライアントには必ず anon key + RLS を使い、service_role は信頼できるサーバー(Edge Function / バックエンド)の中だけに閉じ込めます(詳細は§8の落とし穴で再掲)。
// ✅ クライアント側: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が守る
);
// ⚠️ サーバー側のみ: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 からでも全行が読み書き可能です。
-- ❌ これだけでは無防備。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)。
| 句 | 何を判定するか | 効く操作 |
|---|---|---|
USING | 既存の行が見える/触れるか(読み取り時のフィルタ・更新/削除の対象判定) | SELECT / UPDATE / DELETE |
WITH CHECK | **書き込もうとする行(新しい値)**が許されるか | INSERT / UPDATE |
直感的に言えば、**USING は「入口(どの行を扱えるか)」、WITH CHECK は「出口(どんな行を残せるか)」**です。
操作ごとに必要な句を表にすると、設計の指針になります。
| 操作 | USING | WITH CHECK | 補足 |
|---|---|---|---|
SELECT | ✅ 必須 | — | 見える行を絞る |
INSERT | — | ✅ 必須 | 不正な行の挿入を拒否 |
UPDATE | ✅(対象行) | ✅(更新後の値) | 両方書くのが安全 |
DELETE | ✅ 必須 | — | 消せる行を絞る |
UPDATE が両方を要するのが要点です。USING だけだと「自分のテナントの行を、他テナントのtenant_idに書き換えて逃がす」攻撃を防げません。WITH CHECK で更新後の値も縛ります。
-- 自分のレコードだけ操作できる、最小の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)必須」のように全ポリシーに横断する制約を掛けたいとき。
-- 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 はユーザーが改変できるため認可に使ってはいけません)。
-- 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)**が要ります。
-- ユーザーとテナントの多対多。ロールもここに持つ
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つあります。
- 公開スキーマに置かない。
privateスキーマに置き、Exposed schemas に含めない(RPC経由で外から叩かれないように)。 set search_path = ''を必ず付ける。検索パス経由の関数すり替え攻撃を防ぐため、全オブジェクトをスキーマ修飾で書く。
-- 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;
$$;
これでポリシーは宣言的かつ高速になります。
-- 読み取り:所属テナントなら誰でも(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テストに変換します)。表を更新したらポリシーとテストの両方を更新する——これがロール設計を腐らせないコツです。
-- 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公式のパフォーマンス節には、ベンチ付きの最適化が並んでいます。実務で効く順に。
7-1. auth.uid() を (select auth.uid()) でラップする(最重要)
auth.uid() を裸で書くと行ごとに評価されます。(select ...) で包むと、Postgresオプティマイザが initPlan を立ててステートメント単位で1回だけ評価しキャッシュします。
-- ❌ 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 のようにポリシーの条件に出る列は、主キーでない限り索引が要ります。
-- ポリシーが 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がより良い実行計画を立てます。
// ✅ 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 で当てるほうが速い。
-- ❌ 遅い: 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リクエストはポリシー評価すら走りません。
-- ❌ ロール未指定: 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の権限マトリクスを、そのままテストに落とします。
-- 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;
# CIで実行する検証ゲート(Supabase CLI)
supabase test db
これをマージのブロッキング条件にすれば、「ポリシーをうっかり緩めるPR」がmainに入りません。私のプロジェクトで約280本のポリシーを安心して足し続けられたのは、テナント境界テスト(上記③に相当)を全主要テーブルに張ったからです。
さらに、RLS有効化忘れを機械検出するクエリも検証ゲートに入れます。
-- 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行で。
- 認可はDBに寄せる。UI/APIのif文は迂回される。RLSは全経路に等しく効く最終防衛線。
- ロールを正しく使う。クライアントは
anon+RLS、service_roleはサーバー限定(漏洩=全公開)。 USING=入口、WITH CHECK=出口。UPDATEは両方。テナント境界はas restrictiveでAND固定。- **マルチテナントは membership +
security definer(set search_path = '')**で宣言的・即時・DRYに。 (select auth.uid())ラップ × 索引 ×to指定 を型にし、pgTAPで境界の漏れをCIゲートにする。
私はこの設計で、69テーブル全てにRLS・約280ポリシーを一人で構築・運用しています(題材はリアルタイム試合記録アプリ)。同じプロダクトの「オフライン同時編集の整合性」側の設計はこちらの記事に書きました。
「一人 × 生成AI(Claude Code)で、速く・安く・安全に」——Supabase / PostgreSQL を使ったマルチテナントSaaSの認可設計・RLS監査・パフォーマンス改善のご相談は、お問い合わせからどうぞ。