TL;DR
Supabaseのrow level security(RLS、行レベルセキュリティ)は、メンバーが実際に触れるのと同じ形でテストします。各ロールでサインインし、1つの権限をオフにしてから実際の操作を行い、次にオンにして同じ操作を行い、結果が変わるかを確認します。編集可能なロールが5つある本番アプリでこれを行ったところ、管理者が保存できる87の権限チェックボックスのうち30は、データベースでは何も変えていませんでした。ポリシー単位のテストではこれは見えません。テストしているのは自分が書いたポリシーであって、画面上のチェックボックスではないからです。
Denysです。フルスタック・AIエンジニアです。今年、監査法人向けのプロジェクト・工数管理アプリをSupabaseで構築しました。このアプリでは、アクセスルールがすべてデータベースにあります。以下では、そのルールを証明するために使っている方法、それで見つかったこと、実行にかかるコストを紹介します。
Supabase RLSのテストに、ポリシー単位のテストだけでは足りないのはなぜ?
ポリシーのテストが答えるのは「このポリシーは書いたとおりに動くか」という問いです。ユーザーが知りたいのは別の問いで、「このロールのこのチェックを外したら、その操作はもうできないのか」です。小さなアプリなら、2つの問いの答えは同じです。管理者が権限マトリクスを編集する設定画面があるアプリでは、両者はずれていきます。
このアプリには、RLSを有効にしたテーブルが41、255のマイグレーションにわたって402のCREATE POLICY文がありました。管理画面には、領域(プロジェクト、タスク、タイムシート、ファイルなど)と操作(閲覧、作成、編集、アーカイブ)のマトリクスが、ロールごとに1列ずつ表示されていました。保存された1つ1つのチェックボックスは、管理者への約束です。データベースがその約束を守っているかは、何も確認していませんでした。
標準的なツールはSupabaseがきちんとドキュメント化しています。pgTAPのテストとテストヘルパーを使えば、ユーザーを作成し、そのユーザーに切り替え、読める内容をアサートできます。これは使ってください。以下の方法はその上に重ねるもので、ポリシーの一覧ではなく、管理者が見るマトリクスを起点にします。
マトリクスによる証明はどう動く?
マトリクスの各チェックボックスが1つのプローブになります。プローブはそのロールのメンバーとしてサインインし、実際の操作を1つ行い、結果を記録します。これを2回行います。チェックをオフにした状態で1回、オンにした状態で1回、それ以外の条件はすべて同じです。結果が変われば、そのチェックボックスは効いています。変わらなければ、画面上でどれほどそれらしく見えても、そのチェックボックスは何もしていません。
- マトリクスのチェックボックス1つ例:Standard Member・タスク・作成
- オフ:ロールのメンバーとして操作するアプリと同じ経路でタスクを作成し、結果を記録します。
- オン:同じ操作を繰り返す同じユーザー、同じデータ。変えたのはチェックボックスだけです。
- 2つの結果を比べる判定は、データベースが実際に何をしたかだけで決まります。
- 結果が変わった効いている。オフで拒否、オンで許可。
- どちらも同じ何もしていない。画面は存在しない制御を約束しています。
- 画面とデータベースが食い違うバグ。データベースが拒否するボタンを画面が表示している、またはその逆です。
ここでいう「結果」とは、権限を参照する側が行うことすべてです。ポリシーが行を返す、または書き込みを拒否する。データベース関数が例外を出す。トリガーが止める。Edge Functionが403を返す。1つのチェックボックスがこれらの複数を守っていることもあるため、87のチェックボックスにはロールごとに279のチェックが必要でした。各チェックは次のいずれかの判定で終わりました。
| 判定 | ロールごとのチェック数 | 意味 |
|---|---|---|
| 効果あり | 227 | チェックボックスに合わせて結果が切り替わった |
| 無効 | 30 | オフでもオンでも結果が同じだった |
| 到達不能 | 19 | このロールではそもそも操作に到達できないことを、別途証明した |
| Edge Function | 1 | Edge Functionが制御しており、HTTPで確認した |
| 画面のみ | 1 | 変わるのは画面表示だけで、フロントエンドのテストで確認した |
| 画面とデータベースが一致 | 1 | 画面が提示する操作と、データベースが許可する操作が一致する |
| 失敗 | 0 | 結果が期待と一致しなかったチェック |
証明で何が見つかった?
2026年9月15日の実行では、管理者が編集できる5つのロール(Admin、Senior、Standard、Restricted、複製したカスタムロール)をすべて対象にしました。結果はどのロールでも同じでした。
30のうち、誰かを中に入れてしまうものはありませんでした。29はどのポリシー、関数、トリガーからも一度も参照されておらず、残りの1つはポリシーを書き直した後に意味を失っていました。それでも実害のある問題です。あるロールの「削除」のチェックを外した管理者は、そのロールは削除できないと信じています。30すべてについて、つなぎ込むのではなく非表示にしました。どれも製品としての判断が必要で、その判断をしている間、何もしないチェックボックスを画面に置いておくべきではないからです。
同じ作業で、さらに4つの不具合が見つかりました。
| 何が問題だったか | どう現れたか | 修正 |
|---|---|---|
| 30のチェックボックスが何もしていなかった | 無効の判定。オフでもオンでも結果が同じ | マトリクスから非表示にした |
| 3つのデータベース関数が、担当者のいないタスクで操作を通していた | 拒否されるべきプローブが許可された | 値がない場合、関数は拒否するようにした |
| タイムシートを下書きに戻す操作が、間違ったチェックボックスに依存していた | 1つのロールで画面とデータベースが食い違った | 本来属する「再オープン」の権限で制御するようにした |
| メンバーには、新しい権限が再読み込みの後でしか反映されなかった | データベースは変わっていたが、画面は変わらなかった | 権限を60秒ごとと、ウィンドウにフォーカスしたときに更新 |
| 列のデフォルト値が自身のCHECK制約に違反し、その列を省いたINSERTが失敗していた | 同じ作業の中で、修正のセキュリティレビューで発見 | 制約が許す値にデフォルトを変更した |
重要だったのは、担当者がいない場合のバグです。ガードはPL/pgSQLで次のように書かれていました。
if not (has_permission or assignee_id = auth.uid()) then
raise exception 'forbidden';
end if;
assignee_idがnullだと、assignee_id = auth.uid()はnullになり、false or nullもnull、not nullもnullのままで、ifはnullをfalseとして扱います。例外は決して発生しません。本番では担当者が設定されたタスクが1つもなかったため、これは例外的なケースではなく、対象範囲のすべてのタスクに当てはまっていました。修正では条件をcoalesce(…, false)で包み、値がないときは拒否するようにしました。この修正は残りの変更より先に、単独でリリースしました。
本番のコピーに対してどう実行する?
本番とスキーマが異なるデータベースで証明しても、本番については何も証明できません。ハーネスはローカルのSupabaseスタック上で、本番スキーマのコピーにレビュー中のマイグレーションを加えた別のデータベースで動きます。毎回の実行前に、2つのローカルデータベースのフィンガープリントを比較します。テーブル、ポリシー、関数、権限付与を記述した799行です。違いが1つでもあれば実行は止まります。
- 本番スキーマ信頼できる1台のマシンにコピー。ホスティング型のCIランナーには置きません。
- ローカルスタック:アプリ用データベースローカルのAPIと認証から提供され、エンドツーエンドのチェックで使います。
- ローカルスタック:隔離した証明用データベース同じスキーマで、マトリクスの証明だけに使います。
- フィンガープリントの確認データベースごとに799行。違いがあれば、プローブを1つも動かす前に止まります。
- 各ロールでプローブを実行実際にサインインしたメンバーとして、アプリと同じ経路で操作します。
- 痕跡を残さない終了時の行数は、開始時の行数と一致しなければなりません。
ハーネスを信頼できるものにしたのは、次の2点です。
- まず、わざと壊す。 グリーンの結果を信じる前に、INSERTポリシーの1つをすべてを受け入れるものに置き換えて、もう一度証明を実行しました。そのポリシーが守るチェックボックスでレッドにならなければいけません。壊れたポリシーでもグリーンのままのハーネスは、ただの飾りです。
- 保存済みの状態も確認する。 1回目のパスでは、すべてのロールをクリーンな初期状態から始めます。2回目のパスでは、各ロールの現在保存されているマトリクスから始めます。管理者が実際にチェックした他のチェックボックスとの組み合わせで、挙動が変わることがあるからです。
リリース後もグリーンを保つには?
一度だけ実行した証明は、次のマイグレーションで古くなります。現在、この証明はマイグレーション、認証コード、マトリクス自体に触れるすべてのpushの前に実行されます。
- git push変更がマイグレーション、認証、マトリクスのいずれかに触れている。
- pre-pushフック:マトリクスの証明証明用データベースに対してローカルで実行。レッドならpushを止めます。
- pushがmainに届くデプロイは通常どおり実行されます。
- セルフホストのCIで実行同じ証明をもう一度、本番スキーマのコピーを持つマシンで実行します。
証明は11ファイル・135テストで、同じプロジェクトには通常の単体テストが約3,100あります。
うまくいかなかったことは?
- ホスティング型のCIでは実行できなかった。 証明には本番スキーマのコピーが必要で、それをホスティング型のランナーに置きたくありませんでした。そのため、止めるゲートはpre-pushフックで、セルフホストのマシン上のCIは、デプロイが待たない2つ目の確認です。
- 固定の行数は壊れた。 最初の「痕跡なし」のチェックは、固定の数値と比較していました。次の正当なマイグレーションが権限の行を1つ追加し、チェックがレッドになりました。今は、終了時の行数を開始時の行数と比較しています。
- スキップは合格ではない。 エンドツーエンドのチェックにはローカルのEdge Runtimeが必要です。それが止まっているとチェックはスキップされ、スキップはグリーンと読み違えやすいです。
- 負荷の高いノートPCでは遅い。 負荷のかかったマシンでは、ロールごとのパスにより長いタイムアウトが必要でした。
- 証明できるのは、プローブが記述した範囲だけ。 誰もプローブを書いていないチェックボックスは、安全ではなくカバレッジの抜けとして報告されます。
自分のSupabaseアプリで同じことをするには
- 画面で保存できるすべての権限を一覧にします。ロール×領域×操作です。仕様はこの一覧であり、ポリシーではありません。
- 各チェックボックスについて、それが制御すべき実際の操作と、拒否がどう現れるか(行が返らない、エラー、403)を書き出します。
- 本番と一致するデータベースを作り、フィンガープリントを取ります。スキーマのずれがあれば実行が止まるようにします。
- 各操作を、サインインしたメンバーとして、チェックオフ、次にチェックオンで実行し、比較します。
- ポリシーを1つわざと壊し、証明がレッドになることを確認します。
- 何もしないチェックボックスはすべて非表示にするかつなぎ込み、そのうえで証明をpush前のゲートにします。
アプリにすでにRLSがあっても、設定画面のとおりに動いているか自信がない場合は、同じ方法を既存のコードベースの監査として使えます。プロジェクトの救出の最初のステップです。これらの数字の元になったプロジェクトについては、監査法人の工数管理の事例に書きました。