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にあるからです。両方の組織のメンバーであるユーザーには、両組織のすべての行が通ります。
つまり、アプリ側の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つの組織に属するユーザーを通すかどうかはデータの形に関する問題で、答えられるのは上のクエリだけです。