Supabase で RLS(Row Level Security)を有効化した瞬間、それまで普通に取れていたデータが 全件 0 件 で返ってくる。エラーも出ない。ただ静かに空配列が返ってくる。
マルチテナントの BI プロダクトを Supabase で組んだ時、この罠を2つ続けて踏んだ。 原因を特定するまで2日かかったが、振り返ると 原因はだいたいこの2つしかない と言える。
落とし穴 ①:ポリシーの中で別テーブルを直接 EXISTS している
「ユーザーは自分が紐付いたクライアントのデータだけ見える」というRLSを書くと、 だいたい最初はこう書く。
CREATE POLICY ad_select ON ad_monthly FOR SELECT USING (
EXISTS (
SELECT 1 FROM user_clients
WHERE user_id = auth.uid()
AND client_id = ad_monthly.client_id
)
);
これが動かない。user_clients テーブル自身にも RLS を有効化しているからだ。
ポリシーの中で user_clients を読みに行った瞬間、user_clients 側の RLS もかかる。
ユーザー視点で自分の紐付けレコードだけ見える状態のまま判定が走るので、
ある条件下で EXISTS が常に false になる。あるいはポリシー再帰として警告で止まる。
「テーブルにRLSをかける → そのテーブルを参照するポリシーがまた評価される」という 入れ子構造を Postgres は嫌う。
解決:判定を SECURITY DEFINER 関数に逃がす
ロジックを関数1つに切り出して、SECURITY DEFINER を付ける。
これで関数の中だけは RLS をバイパスして user_clients を素直に読める。
CREATE OR REPLACE FUNCTION can_access_client(target UUID)
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM user_clients
WHERE user_id = auth.uid()
AND (role = 'admin' OR client_id = target)
);
$$ LANGUAGE sql SECURITY DEFINER;
ポリシーはこれを呼ぶだけになる。
CREATE POLICY ad_select ON ad_monthly FOR SELECT USING ( can_access_client(client_id) );
CREATE POLICY ad_insert ON ad_monthly FOR INSERT WITH CHECK ( can_access_client(client_id) );
CREATE POLICY ad_update ON ad_monthly FOR UPDATE USING ( can_access_client(client_id) );
ポリシーが テーブルごとに違う条件を持たない ようにするのがポイントだ。
新しいテーブルを足す時も、can_access_client(client_id) を貼るだけで終わる。
仕様が変わっても、関数1つ直せば全テーブルに伝播する。
落とし穴 ②:UUID と TEXT の型不一致
落とし穴①を解消した後、別のテーブルを足した時にまた0件問題が再発した。
そのテーブルは外部サービス(Shopify)のIDをそのまま保存する都合で、
client_id を TEXT 型 で持っていた。既存テーブルは UUID 型。
ポリシーに can_access_client(client_id) をそのまま貼ると、こうなる。
ERROR: function can_access_client(text) does not exist
Postgres は UUID と TEXT を暗黙キャストしてくれない。 書き換えは1行だけだ。
USING ( can_access_client(client_id::uuid) )
これが見出しの「can_access_client(client_id::uuid) パターン」。
型が違うテーブルでも、キャスト1つで同じ関数を使い回せるのが大事で、
わざわざ別関数を作ったり、ポリシーごとに条件を書き直したりしないで済む。
なぜこの設計に落ち着くか
落とし穴を抜けた先で気づいたのは、RLS は「ポリシー記法」より「設計思想」の問題 だ ということだった。
ポリシー1つ1つに条件を書くと、テーブルが増えるたびに条件が散らばる。
ある時から「role = 'admin' の判定を全テーブルに足す」みたいな改修が出てきて、
何箇所書き換えればいいか分からなくなる。
判定を関数1つに集約しておくと、
- ポリシーは関数を呼ぶだけ
- 仕様変更は関数の中だけで完結
- 新しいテーブルを足す時も、同じ1行を貼るだけ
になる。RLS のメンテナンスコストは、ポリシーの数ではなく分岐の数で決まる、 というのが2日詰まった後の結論だ。
RLS は強力だが、間違えるとエラーすら出さずに静かに動かなくなる。 最初にこの形を作っておくと、後から増えるテーブルでも迷わない。 同じところで詰まっている人の助けになれば嬉しい。