---
title: "Supabase RLS：2つの組織に属すユーザーで起きる漏洩"
description: "メンバーシップを確認するRLSポリシーは、2つの組織に属するユーザーにはどちらでも真です。プローブの結果、修正、2分の確認方法を紹介します。"
url: "https://madsalt.tech/ja/notes/supabase-rls-user-in-two-organizations/"
author: "Denys Zaichenko"
language: "ja"
published: 2026-10-18
---

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

TL;DR

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

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

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

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

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

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

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

ポリシーはメンバーシップのテストです。Postgresは[row securityのドキュメント](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)にあるとおり行ごとに評価しますが、リクエストがどの組織として動いているかは知りません。それはアプリ側、つまりURL、Cookie、組織の切り替えUIにあるからです。両方の組織のメンバーであるユーザーには、両組織のすべての行が通ります。

![リクエストの組織はポリシーの中にない。組織Aと組織Bのメンバーである1人のユーザーが、組織Aで作業している。RLSポリシーのみ：組織Aでのリクエスト → 行の組織のメンバー？ → はい（AまたはB） → 組織Bの行が返る、RLS＋このリクエストの組織：組織Aでのリクエスト → 行の組織のメンバー？ → 行は組織A？ → 組織Bからは0行。](https://madsalt.tech/charts/ja/supabase-rls-user-in-two-organizations.svg)

*2つの組織に属するテストユーザーでのプローブ、2026年9月。RLSだけでは組織Bのドキュメントを読み書きでき、リクエストの組織を加えると0行でした。*

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

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

## 何が修正になったのか？

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

```ts
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エディタで、そのユーザーとして動き、もう一方の組織の行を要求します。

```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のテスト方法](https://madsalt.tech/ja/blog/how-to-test-supabase-rls/)と同じです。各ポリシーが何を言っているかだけでなく、実在のユーザーが実際のデータ形状で何をできるかをテストすることです。関連するノートが3つあります。[サインアップのメタデータから取られたロール](https://madsalt.tech/ja/notes/supabase-raw-user-meta-data-role/)、[権限チェックを飛ばしたNULL](https://madsalt.tech/ja/notes/plpgsql-if-not-null-fails-open/)、[Data APIが提供するスキーマ](https://madsalt.tech/ja/notes/supabase-exposed-schemas/)です。2社テストは、私の[Lovableアプリのセキュリティチェックリスト](https://madsalt.tech/ja/blog/lovable-app-security-checklist/)の最初の項目です。その周りのポリシー、grant、設定については、無料の[Supabase セキュリティチェック](https://madsalt.tech/ja/tools/supabase-security-check/)が46件のOWASP Top 10:2025ルールを実行します。ポリシーが2つの組織に属するユーザーを通すかどうかはデータの形に関する問題で、答えられるのは上のクエリだけです。
