コンテンツにスキップ

0003. マルチテナント分離を RLS で実現する

コンテキストと課題

複数の企業 (テナント) が同一 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 stableapp_metadata ->> 'tenant_id')、tenants の SELECT ポリシー。
  • supabase/migrations/00000000000002_fix_manifests_rls.sqlmanifests のポリシーをヘルパー利用に修正 (直接 request.jwt.claims ->> 'tenant_id' は null になる問題の修正)。
  • supabase/migrations/00000000000003_tenant_types_and_roles.sql:44-68current_tenant_type() / current_user_role()(いずれも language sql stableapp_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.mdauth.users.raw_app_meta_data(tenant_id / tenant_type / role) → JWT app_metadata の流れと「1 ユーザー = 1 テナント」方針。