0017. マルチテナント RLS のハードニング (越境参照・特権列の保護)¶
- ステータス: Accepted(実装済み)
- 関連: 0003 マルチテナント分離を RLS で実現、0006 工場スコープのアクセス制御、0005 ロールモデル
コンテキストと課題¶
マルチテナント分離は RLS が担いますが (ADR 0003)、初期の数本の ポリシーに 越境 (cross-tenant) の穴 と 特権列の保護不足 が残っていました。本番初使用前に 4 件をまとめてハードニングします。いずれも「自テナント完結の正規操作は不変」のまま、過剰公開と 越境書込だけを塞ぐ 加法的・冪等 な是正です。
- 越境 SELECT の過剰公開 (migration 5):
permits/service_areas/pricing_rulesがusing (true)等で 認証済みユーザー全員 に開放され、競合 collector・driver を含む全テナントに 他社の許可証・対応エリア・料金体系が露出していた。 tenantsの特権列が誰でも書ける (migration 1): UPDATE ポリシーにロール制約が無く、driver / normal を含む同テナント全員がテナント設定 (name) を変更でき、さらにtype(discharger / collector) すら書き換え可能だった。type 変更は整合性を破壊する致命的操作。- 子テーブルの参照列が越境しうる (migration 41):
dispatch_stopsの書込ポリシーがdriver_id(profiles) /collection_site_id(collection_sites) の同一テナントを検証せず、直接 client INSERT/UPDATE で他テナントの値を紐付け可能だった。FK は値の存在のみを保証し、テナント越境は 防げない。 manifests.job_idが越境関連付けしうる (migration 10): nullable FKjob_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_immutable で new.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_sites
も tenant_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) と同型の越境参照ハードニングを横乗りにも適用 (名前解決の罠の対策を踏襲)。