本文へスキップ

Supabase RLS:2つの組織に属すユーザーで起きる漏洩

Supabase

Markdownで表示Claudeで開くChatGPTで開くPerplexityで開く

質問もコピーされます。チャットが空で開いた場合は貼り付けてください。

TL;DR

auth_in_org(organization_id)のようなポリシーは、「呼び出し元は、この行を所有する組織のメンバーか」を問います。組織Aと組織Bの両方に属するユーザーなら、答えはどちらでも「はい」です。私のプローブでは、RLSだけだと、そのようなユーザーはもう一方の組織のドキュメントを読み書きできました。現在のリクエストの組織をクエリに加えると、結果は0行になりました。

これは、マルチテナントのSupabaseアプリである建設ドキュメントプラットフォームを堅牢化している最中に見つけました。すべてのテナントテーブルでrow level securityが有効かつ強制されていたため、対応は完了したように見えていました。

何が安全に見えていたのか?

どのポリシーも同じ形でした。行のorganization_idが、呼び出し元がメンバーである組織でなければならない、というものです。そのため、いくつかのサーバーアクションはIDだけでドキュメントを検索していました。「どうせRLSが絞り込むから」という理屈です。

const { data } = await supabase
  .from('documents')
  .select('*')
  .eq('document_id', id)
  .single()

所属が1つだけのユーザーならこれで動きますし、そのユーザーで書いたテストも通ります。

なぜRLSは別の組織を通してしまうのか?

ポリシーはメンバーシップのテストです。Postgresはrow securityのドキュメントにあるとおり行ごとに評価しますが、リクエストがどの組織として動いているかは知りません。それはアプリ側、つまりURL、Cookie、組織の切り替えUIにあるからです。両方の組織のメンバーであるユーザーには、両組織のすべての行が通ります。

リクエストの組織はポリシーの中にない。組織Aと組織Bのメンバーである1人のユーザーが、組織Aで作業している。RLSポリシーのみ:組織Aでのリクエスト → 行の組織のメンバー? → はい(AまたはB) → 組織Bの行が返る、RLS+このリクエストの組織:組織Aでのリクエスト → 行の組織のメンバー? → 行は組織A? → 組織Bからは0行。
2つの組織に属するテストユーザーでのプローブ、2026年9月。RLSだけでは組織Bのドキュメントを読み書きでき、リクエストの組織を加えると0行でした。

つまり、アプリ側のorganization_idフィルターは、RLSの上に重ねた二重の防御ではありません。このユーザーにとっては、唯一の制御です。

そのヘルパーを見ているついでに、organizations.is_activeを確認しているかも調べてください。メンバーシップのヘルパーは確認していないことが多く、その場合、無効化された組織でもRLSの層では読み書きできてしまいます。is_activeの確認がアプリのテナント解決処理にしかないなら、その処理を通らないクエリは、無効化も素通りします。

何が修正になったのか?

リクエストの組織を、テナントデータを読み書きするすべてのサービス関数の必須引数にしました。引数がないと関数はコンパイルできないため、呼び出し側が渡し忘れることはありません。

export async function getDocument(orgId: string, id: string) {
  return supabase
    .from('documents')
    .select('*')
    .eq('organization_id', orgId)
    .eq('document_id', id)
    .single()
}

レビューから、さらに2つのルールが出ました。

  • 書き込もうとしている行から組織を取らないこと。update ... where id = $1 and organization_id = <その行自身のorganization_id>は常に真になるため、何も守りません。
  • (id, organization_id)の複合外部キーにすると、データベースは異なる組織の行どうしの紐付けを拒否します。すべての子テーブルを確認してください。3つに付けて4つ目を忘れるのは簡単です。

自分のアプリをどう確認する?

2つの組織で有効なメンバーシップを持つユーザーを1人用意します。次に、ローカルまたはステージングのコピーのSQLエディタで、そのユーザーとして動き、もう一方の組織の行を要求します。

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "<user id>", "role": "authenticated"}';

-- RLS alone: returns the org B document
select document_id from documents where document_id = '<org B document id>';

-- with the org of the request: 0 rows
select document_id from documents
where document_id = '<org B document id>'
  and organization_id = '<org A id>';
rollback;

updateでも同じことを行い、2つ目の組織を非アクティブにしてからもう一度行います。最初のクエリが行を返したら、テナントデータをIDだけで検索している箇所をすべて確認してください。

どれくらい深刻か?

正直に見積もってください。攻撃者には両方の組織での正規のメンバーシップが必要です。そのため、これは完全性と監査証跡の問題です。2つの顧客にアクセスできる人が誤って別の顧客を編集し、ログには許可されていたと残ります。見知らぬ人がすべてのテナントを読める経路ではありません。

一般的な教訓は、Supabase RLSのテスト方法と同じです。各ポリシーが何を言っているかだけでなく、実在のユーザーが実際のデータ形状で何をできるかをテストすることです。関連するノートが3つあります。サインアップのメタデータから取られたロール、権限チェックを飛ばしたNULL、Data APIが提供するスキーマです。2社テストは、私のLovableアプリのセキュリティチェックリストの最初の項目です。その周りのポリシー、grant、設定については、無料のSupabase セキュリティチェックが46件のOWASP Top 10:2025ルールを実行します。ポリシーが2つの組織に属するユーザーを通すかどうかはデータの形に関する問題で、答えられるのは上のクエリだけです。

© 2026 Denys Zaichenko. 元の記事: https://madsalt.tech/ja/notes/supabase-rls-user-in-two-organizations/