0003. マルチテナント分離を RLS で実現する¶
- ステータス: Accepted(実装済み)
- 関連: 0002 Supabase を採用 / 0005 ロールモデル / 0006 工場スコープのアクセス制御
コンテキストと課題¶
複数の企業 (テナント) が同一 DB を共有する B2B SaaS では、あるテナントが他テナントのデータを
一切読み書きできない ことを保証しなければならない。クライアント (Web / Mobile) が Supabase に
直接アクセスする構成 (ADR 0002) では、テナント分離を
アプリ層の where 句に頼ると実装漏れが即セキュリティ事故になる。分離は DB 側で強制したい。
決定¶
テナント分離を PostgreSQL の RLS で実現し、判定は JWT の app_metadata から行う。
最小テナントモデルは「1 ユーザー = 1 テナント」。auth.users.raw_app_meta_data.tenant_id に
ユーザーを紐付け、サインイン時に JWT へ自動エンコードされる app_metadata.tenant_id を RLS が読む。
判定用の SQL ヘルパー関数 を用意し、各テーブルのポリシーから共通利用する:
-- migration 00000000000001_tenants.sql
create or replace function public.current_tenant_id()
returns uuid language sql stable as $$
select nullif(
current_setting('request.jwt.claims', true)::jsonb
-> 'app_metadata' ->> 'tenant_id', ''
)::uuid
$$;
current_user_role() (app_metadata.role) と current_tenant_type() (app_metadata.tenant_type) も
同じ形 (migration 3)。ポリシーは例えば次のように current_tenant_id() で自テナント行に限定する:
create policy "user can read own tenant" on public.tenants
for select to authenticated using (id = public.current_tenant_id());
重要: なぜ app_metadata 経由か / なぜ SECURITY DEFINER ではないか¶
- 最初の
manifests(migration 0) はポリシー内でrequest.jwt.claims ->> 'tenant_id'を直接 参照していたが、Supabase の JWT 構造ではtenant_idはトップレベルではなくapp_metadata配下にあるため null になっていた。これを修正するため migration 1/2 でヘルパーを導入し、-> 'app_metadata' ->> 'tenant_id'を読むようにした。 - これら 3 つのヘルパーは
language sql stableで、SECURITY DEFINER ではない (= 既定の SECURITY INVOKER)。読むのは呼び出し元自身の JWT クレーム (current_setting('request.jwt.claims')) だけで、他者の権限を借りる必要がないため。 (テーブルを横断参照するcurrent_user_can_access_factory()のみ SECURITY DEFINER。 → ADR 0006)
影響 (Consequences)¶
良い点¶
- テナント分離が DB に強制 されるため、クライアントが直接 DB を叩いてもアプリ層の
where漏れによる越権が起きない。 - ヘルパー関数を
create or replaceで冪等に再定義でき、全テーブルのポリシーが同じ判定基準を共有する。 - ロール (
current_user_role()) やテナント種別 (current_tenant_type()) も同じ仕組みで参照でき、 認可をポリシー内で完結できる。
トレードオフ / 注意点¶
- 判定の 権威が JWT クレーム にあるため、
tenant_id/roleの変更は トークン再取得 まで 反映されない。raw_app_meta_dataはサーバ側 (service_role) でのみ書き換え可能。 - 「1 ユーザー = 1 テナント」前提。多対多のメンバーシップが必要になれば再設計が要る
(現状は Auth Hook 不使用・
app_metadata経路で十分という判断)。 - RLS は permissive(OR) 評価 であり、ポリシーの足し方を誤ると可視範囲が意図せず広がる。 分離だけでなく「絞り込み」を行う場合の落とし穴は ADR 0006 を参照。
根拠 (典拠)¶
supabase/migrations/00000000000001_tenants.sql:1-10,26-64— テナントモデルの説明、tenantsテーブル、current_tenant_id()(language sql stable、app_metadata ->> 'tenant_id')、tenantsの SELECT ポリシー。supabase/migrations/00000000000002_fix_manifests_rls.sql—manifestsのポリシーをヘルパー利用に修正 (直接request.jwt.claims ->> 'tenant_id'は null になる問題の修正)。supabase/migrations/00000000000003_tenant_types_and_roles.sql:44-68—current_tenant_type()/current_user_role()(いずれもlanguage sql stable、app_metadata参照)。supabase/migrations/00000000000008_profiles.sql:1-11,47-53— 直接request.jwt.claims ->> 'tenant_id'だと null になる旨のコメントと、current_tenant_id()+current_user_role()を使う admin 限定 UPDATE ポリシー。docs/auth-and-tenants.md—auth.users.raw_app_meta_data(tenant_id / tenant_type / role) → JWTapp_metadataの流れと「1 ユーザー = 1 テナント」方針。