コンテンツにスキップ

0017. マルチテナント RLS のハードニング (越境参照・特権列の保護)

コンテキストと課題

マルチテナント分離は RLS が担いますが (ADR 0003)、初期の数本の ポリシーに 越境 (cross-tenant) の穴特権列の保護不足 が残っていました。本番初使用前に 4 件をまとめてハードニングします。いずれも「自テナント完結の正規操作は不変」のまま、過剰公開と 越境書込だけを塞ぐ 加法的・冪等 な是正です。

  1. 越境 SELECT の過剰公開 (migration 5): permits / service_areas / pricing_rulesusing (true) 等で 認証済みユーザー全員 に開放され、競合 collector・driver を含む全テナントに 他社の許可証・対応エリア・料金体系が露出していた。
  2. tenants の特権列が誰でも書ける (migration 1): UPDATE ポリシーにロール制約が無く、driver / normal を含む同テナント全員がテナント設定 (name) を変更でき、さらに type (discharger / collector) すら書き換え可能だった。type 変更は整合性を破壊する致命的操作。
  3. 子テーブルの参照列が越境しうる (migration 41): dispatch_stops の書込ポリシーが driver_id (profiles) / collection_site_id (collection_sites) の同一テナントを検証せず、直接 client INSERT/UPDATE で他テナントの値を紐付け可能だった。FK は値の存在のみを保証し、テナント越境は 防げない
  4. manifests.job_id が越境関連付けしうる (migration 10): nullable FK job_id の紐付け先 job が 同一テナントかを未検証で、/register 由来 deep-link の URL 改変で他テナントの job_id を 紐付け可能だった。

決定

越境参照は「相手が取引相手 (discharger) であるとき」だけに絞り、特権列は admin 限定 + トリガで 固め、子テーブル書込は親/参照行の同一テナントを WITH CHECK の exists で検証する。

(a) 越境 SELECT は discharger からの探索だけに限定 (mig53)

permits / service_areas / pricing_rules の越境 SELECT を、 using (tenant_id <> current_tenant_id() and current_tenant_type() = 'discharger') に縮小する (migration 43 が vehicles に行った縮小と同型)。pricing_rules は従来どおり active = true も AND する。 自テナント参照は別ポリシー ("collector manages own ...") が温存するため非破壊。排出事業者の マッチング探索 (DischargerMatchingPage) は discharger 視点の越境 SELECT として引き続き読める。

(b) tenants は admin のみ UPDATE 可 + type 不変トリガ (mig54)

UPDATE ポリシーを id = current_tenant_id() and current_user_role() = 'admin' に限定する。さらに BEFORE UPDATE トリガ tenants_guard_type_immutablenew.type <> old.type一律禁止 する (defense-in-depth。service_role 等ポリシーをバイパスする経路にも効く)。アプリ層に tenants の client UPDATE 経路は無いため非破壊で、discharger は signup で role='admin' 固定のため自テナント更新は可能なまま。

(c) 子テーブル書込は親/参照行の同一テナントを WITH CHECK で検証 (mig55)

dispatch_stops の FOR ALL ポリシーの WITH CHECK に、新行の driver_id (profiles) と collection_site_id (collection_sites) が現テナントに属することを exists で検証する条件を AND する。 USING は現状維持 (SELECT/DELETE を絞らない)。生成 RPC は SECURITY DEFINER かつテナントスコープ済み コースから採るため通過し、正規の D&D も通る。

名前解決の罠 (相関サブクエリの未修飾 tenant_id)

相関サブクエリ内で 未修飾の tenant_id を書くと、内側テーブル (profiles / collection_sitestenant_id 列を持つ) に解決され p.tenant_id = p.tenant_id (恒真) となり、検証が 無効化 されます。そのため必ず (a) 相関列は dispatch_stops.<col>完全修飾 し、(b) テナント一致は WITH CHECK が既に保証している tenant_id = current_tenant_id() を利用して public.current_tenant_id()直接比較 します。同じ罠と対策は横乗り (ADR 0016、mig57) にも適用されています。

(d) manifests.job_id は同一テナントの job のみ (mig56)

INSERT / UPDATE の WITH CHECK に manifests.job_id is null or exists (同一テナントの jobs) を AND する。 jobs は discharger 所有 (ensure_discharger_tenant) かつ自テナント完結のため、current_tenant_id() と 一致する job のみ通過する。job_id 無しのスタンドアロン manifest は従来どおり通過。

影響 (Consequences)

良い点

  • 競合 collector・driver から他社の許可証 / 対応エリア / 料金が見えなくなり、越境探索は 取引相手 (discharger) のマッチング目的に限定される。
  • type を不変化したことで、整合性トリガ群 (ensure_collector/discharger_tenant) や既存データを破壊する type 改変経路を塞いだ (ポリシー + トリガの二重防御)。
  • 子テーブルの参照列に他テナントの値を混入できなくなった。「FK は値の存在のみを保証し、テナント越境は 防げない」 という前提を WITH CHECK の exists で補強した。
  • いずれも drop policy if exists → create / create or replace冪等 な是正で、自テナント完結の 正規操作 (SELECT / 正規 D&D / 正規 deep-link) は不変。

トレードオフ / 注意点

  • WITH CHECK に exists サブクエリが増えるため、書込時に参照行の存在確認コストがわずかに乗る (件数規模では無視できる)。
  • 相関サブクエリでは 列の完全修飾が必須 という規律が残る。未修飾だと恒真化して黙って無効になるため、 同型のポリシーを追加する際は必ず <table>.<col> で修飾する (mig55 / mig57 のコメント参照)。
  • 越境 SELECT を current_tenant_type() = 'discharger' に依存させるため、将来テナント種別を増やす場合は 各越境ポリシーの見直しが要る。

根拠 (典拠)

  • supabase/migrations/00000000000053_collector_profile_cross_tenant_discharger_only.sql:30-65 — (a) permits / service_areas / pricing_rules の越境 SELECT を tenant_id <> current_tenant_id() and current_tenant_type() = 'discharger' (+ pricing は active = true) に縮小。
  • supabase/migrations/00000000000054_tenants_update_admin_only_type_immutable.sql:32-65 — (b) UPDATE を admin 限定 + tenants_guard_type_immutable トリガ (type 変更を一律禁止)。
  • supabase/migrations/00000000000055_dispatch_stops_write_same_tenant_refs.sql:19-60 — (c) WITH CHECK で driver_id (profiles) / collection_site_id (collection_sites) の同一テナントを exists 検証。名前解決の罠 (未修飾 tenant_id の恒真化) と完全修飾 + current_tenant_id() 直接比較の対策。
  • supabase/migrations/00000000000056_manifests_job_id_same_tenant.sql:28-65 — (d) INSERT / UPDATE の WITH CHECK に job_id is null or exists (同一テナントの jobs) を AND。
  • supabase/migrations/00000000000057_dispatch_ride_alongs.sql:59-86 — (c) と同型の越境参照ハードニングを横乗りにも適用 (名前解決の罠の対策を踏襲)。