はじめに
🤔「RLS のポリシー、ちゃんと効いているかどうやって確かめよう…」
😥「テストは通っているけど、本当に守れているのか自信がない…」
🫠「そもそも、データが漏れていたら気づけるんだろうか…?」
RLS(Row Level Security)は、書くこと自体はそれほど難しくありません。
難しいのは、書いたルールが正しく効き続けているかを確かめることです。
個人開発している口コミサービス あじぴた には、
本人以外には絶対に見せてはいけないデータがあります🔒
そして、このデータが漏れても、エラーは 1 つも出ません。
この記事では、そういうデータを守るために
- RLS のテストを、どう書くと「静かに壊れた」ことに気づけるか
- 行を隠しても、別の列や件数から漏れることがある、という話
- 退会したユーザーのデータを、種類ごとにどう扱い分けたか
を、実際のテストコードと一緒に書きます🧪
この記事は、個人開発サービス「あじぴた」の設計を書くシリーズの 1 本です。
単体で読めるように書いていますが、サービスの全体像はハブ記事にまとめています。
守りたいもの:本人にしか見えない「合わなかった」
あじぴたは、味の好みが近い人の「美味しかった」を頼りに、自分好みの一皿を探すサービスです。
公開されるのは「美味しかった」だけです。
その代わりに、「自分には合わなかった」を記録する機能があり、これは本人以外には見えません。
あじぴたでは、この仕様をサイレントネガティブと呼んでいます。
この記録は、外に漏れた瞬間にサービスの前提が崩れます。
「誰が何に合わなかったを付けたか」が見えてしまえば、ユーザーは二度と正直に記録しなくなるからです⚠️
そのため、口コミのテーブルにはこんな RLS を付けています。
-- positive(美味しかった)は誰でも読める
-- negative(合わなかった)は本人だけが読める
create policy reviews_select on reviews
for select to authenticated, anon
using (
eval_type = 'positive'
or user_id = (select auth.uid())
);
なぜアプリのコードではなく RLS で守ることにしたのか、
そしてそれがデータベースの選定まで決めた経緯は、前回の記事で書きました👇
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由
この記事は、その続きです。
RLS で守ると決めたあと、それが守れていることをどう確かめ続けるかの話をします。
RLS のテストは、データベースの中で書く
あじぴたでは、データベースのテストに pgTAP を使っています。
PostgreSQL の中で、SQL でテストを書けるツールです。Supabase CLI から supabase test db で実行できます。
| 手段 | RLS をテストできるか | 理由 |
|---|---|---|
| アプリの単体テスト(Vitest など) | △ | データベースを差し替えることが多く、本物の RLS を通らない |
| E2E テスト(Playwright など) | △ | 画面に出ないことは確かめられるが、遅く、どの条件で隠れたのかが分からない |
| pgTAP | ✅ | 本物のテーブルと本物のポリシーに対して、ロールを切り替えて直接確かめられる |
各テストは 1 つのトランザクションの中で動き、最後に rollback するので、データベースを汚しません。
ブラウザもビルドも要らないので、数秒で終わります⚡
現在、pgTAP のテストは 85 ファイル・1,255 アサーションあります。
GitHub Actions で、develop と main に向けた Pull Request のたびに自動で走らせています。
「静かに壊れる」から、普通に書くと通ってしまう
RLS のテストを書くとき、一番気をつけたのはこの性質です。
他人の「合わなかった」が見えるようになっても、エラーは出ない。
ただ、見えてはいけない行が 1 件増えるだけ。
逆に言えば、テストの書き方を間違えると、壊れていても緑のままになります😇
実際のテストに入れている工夫を、順に紹介します。
① 「0 件なら合格」にしない — 対照を置く
素朴に書くと、こうなります(記事用に、ユーザー ID などは簡略化しています)。
-- B さんとして、A さんの「合わなかった」を数える
select is(
(select count(*)::int from reviews
where user_id = :'ua' and eval_type = 'negative'),
0,
'B さんは A さんの「合わなかった」を読めない'
);
一見よさそうですが、これはテストデータを入れ忘れても通ります。
最初から 1 件も無ければ、RLS が壊れていても 0 件です🫠
そこで、同じ人の「美味しかった」が読めることも一緒に確かめます。
-- 対照: 同じ A さんの「美味しかった」は読める
select is(
(select count(*)::int from reviews
where user_id = :'ua' and eval_type = 'positive'),
1,
'対照: B さんは A さんの positive を読める(データは入っている)'
);
こうすると、データの入れ間違いや、検索条件の書き間違いではないことが分かります。
2 つのクエリの違いは eval_type だけなので、
1 件と 0 件に分かれたなら、その差を生んだのは RLS の条件だけだと言い切れます✅
なお、「合わなかった」の行そのものが入っていることは、
③で書く「本人には自分の記録が見える」テストで確かめています。
② 検証したい条件以外では「見える」状態にしておく
あじぴたの RLS には、実はもう 1 つ条件があります。
運営が非表示にした店舗の口コミは、誰にも見せないというものです。
ここに落とし穴があります。
テスト用の店舗をうっかり非表示で作ると、店舗の条件だけで全部見えなくなります。
すると、「合わなかった」を隠す条件が壊れていても、テストは通ってしまいます。
そこで、テスト用の店舗は必ず表示中にして、
「合わなかったかどうか」の条件だけが結果を左右する状態にしています。
店舗の非表示のほうは、別のテストファイルで同じように固定しています。
③ 両方向から確かめる
テストデータは、2 人のユーザーと 2 つの料理を交差させて作っています。
| 料理 1 | 料理 2 | |
|---|---|---|
| A さん | 美味しかった | 合わなかった |
| B さん | 合わなかった | 美味しかった |
こうすると、どちらの視点から見ても
- 他人の「合わなかった」(見えてはいけない)
- 自分の「合わなかった」(本人には見える)
- 他人の「美味しかった」(見えてよい)
の 3 つが同時に揃います。
A さんから見たとき、B さんから見たとき、ログインしていない人から見たときの 3 方向で確かめています👀
本人には自分の記録が見えることも、ちゃんと確かめます。
隠しすぎて本人にも見えなくなるのも、それはそれで壊れているからです。
④ ポリシーが消えたこと、RLS が切れたことにも気づく
最後に、ポリシーの存在そのものと、RLS が有効になっていることを確かめています。
select is(
(select count(*)::int from pg_policies
where tablename = 'reviews' and policyname = 'reviews_select'),
1,
'reviews_select ポリシーが存在する'
);
select is(
(select relrowsecurity from pg_class where oid = 'public.reviews'::regclass),
true,
'reviews は RLS が有効(無効化されるとポリシーごと素通りする)'
);
RLS は、テーブル単位で無効にできてしまいます。
無効にすると、ポリシーが正しく書かれていても全部素通りです🙅
マイグレーションのどこかで誤って切ってしまったときに、ここで止まります。
⑤ 件数は、テスト用のデータに絞って数える
pgTAP は、開発用のサンプルデータが入った状態のデータベースに対して走ります。
そのため、テーブル全体を count(*) すると、サンプルデータを足しただけでテストが壊れます。
件数を数えるときは、必ずテスト用に作った料理に絞って数えています。
まとめると
| 工夫 | 防いでいること |
|---|---|
| ① 対照を置く | データの入れ忘れで、壊れたまま通る |
| ② 他の条件では見える状態にする | 別の条件のおかげで、たまたま通る |
| ③ 両方向 + 未ログインから確かめる | 片側だけ守れている状態 |
| ④ ポリシーと RLS の有効化を確かめる | ポリシーごと消える・RLS が切れる |
| ⑤ テスト用のデータに絞る | サンプルデータの変更で揺れる |
どれも、**「このテストは、壊れたときに本当に落ちるか?」**を考えて足したものです🔍
行を隠しても、漏れることがある
ここまでで、「合わなかった」の行そのものは守れるようになりました。
ところが、行は見えないのに、情報は漏れるという経路が、2 つ見つかりました。
1 つ目は機能を足す設計の段階で気づき、2 つ目はその対応のコードレビューで見つかったものです。
「誰が」が漏れる — 料理を最初に登録した人
あじぴたに、まだ口コミが 1 件も無い店舗でも、料理に「合わなかった」を記録できる機能を足すことにしました。
この場合、記録と一緒に料理そのものも新しく作られます。
料理のテーブルには、最初にその料理を登録した人を記録する列があります。
この列が誰でも読める状態のまま機能を足すと、こう推測できるようになります。
口コミが 1 件も公開されていない料理なのに、「最初に登録した人」が入っている
→ 😱 その人が、この料理に「合わなかった」を付けた
口コミのテーブルは RLS で守れていても、料理のテーブルの列から、誰が付けたかが分かってしまうわけです。
この列は、画面には一度も表示していません。
普通にアプリを使っているだけなら、目に入ることはありません。
ただ、Supabase では、データベースの API を呼ぶための公開キーがブラウザに配られます。
そのキーを使えば、画面を通さずに、テーブルの列を直接読みにいけます。
「画面に出していない」は守りにならず、読めるかどうかを決めるのは、データベースの権限だけです。
次の「何人が」も、同じく画面には出していない列の話です。
「何人が」が漏れる — 料理ごとの記録人数
1 つ目を塞いだときのレビューで、同じ形の問題がもう 1 つ見つかりました。
料理のテーブルには、内部の集計用にその料理を記録した人数を持たせていました。
この人数は「美味しかった」と「合わなかった」の両方を数えています。
そして、公開されている「美味しかった」の件数は、誰でも数えられます。
記録した人数 − 公開されている「美味しかった」の件数 = 「合わなかった」の件数
引き算 1 回で、非公開の件数が分かってしまいます😇
「誰が」は隠せていたのに、「何人が」が残っていたわけです。
直し方:列ごとに読める権限を絞る
どちらも、列単位で読み取り権限を外すことで塞ぎました。
PostgreSQL では、テーブル全体ではなく列ごとに GRANT / REVOKE できます。
「テーブルの読み取り権限をいったん全部外し、公開してよい列だけ付け直す」形にしています。
内部の集計は特別な権限で動く関数の中で行っているので、
アプリから読めなくなっても困りません。
テストでは、その列が読めないことを確かめています。
select ok(
not has_column_privilege('anon', 'public.dishes', 'first_posted_by', 'select')
and not has_column_privilege('authenticated', 'public.dishes', 'first_posted_by', 'select'),
'first_posted_by は anon / authenticated から SELECT できない'
);
同時に、公開してよい列は読めることも確かめています。
付け直しを 1 つ忘れると、料理一覧や検索が軒並み読めなくなるからです。
ここでも、①と同じく対照を置く考え方です。
そして、このテストは本当に落ちるかを実際に試しました。
権限を戻すと、該当のアサーションだけが落ち、外し直すと通ることを確認しています✅
RLS が守るのは「行」まで。
隠したいものは、ほかの列や件数から推測できないかまで考える必要があります。
「持たないこと」をテストする
漏れないようにする一番確実な方法は、そもそも持たないことです。
あじぴたには、外部 API を何回呼んだかを日ごとに数えるテーブルがあります。
コストの見積もりに使うもので、誰が検索したかは知る必要がありません。
そこで、このテーブルの列の一覧そのものをテストで固定しています。
-- 列が増えたら落ちるように、列の集合を固定する
select is(
(select array_agg(column_name::text order by column_name)
from information_schema.columns
where table_schema = 'public' and table_name = 'store_search_call_daily'),
array['calls', 'day', 'paged', 'source'],
'列は 4 つだけ(user_id・ip・keyword などを持たない)'
);
普通、テストは「あるべきものがあるか」を確かめます。
でもプライバシーに関わる設計では、**「無いべきものが無いか」**のほうが大事です🧪
将来、「ユーザー ID の列があれば分析に便利だな」と思って足したとき、
テストが落ちて、持たないと決めた理由を思い出せます。
コメントに書いておくだけでは、こうはなりません。
退会したら、「合わなかった」は残さない
最後に、退会したユーザーのデータの扱いです。
地味ですが、サイレントネガティブを守るうえで、ここも外せませんでした。
全部消すと、その人が書いた口コミが消え、料理のページが空になっていきます。
全部残すと、個人の情報が残り続けて、退会の意味がありません。
そこで、データの種類ごとに扱いを変えました。
| データ | 扱い | 理由 |
|---|---|---|
| 個人の情報 | 削除 | 退会したので当然 |
| 「美味しかった」 | 匿名化して残す | もともと公開している。料理ページの情報を保つ |
| 「合わなかった」 | 完全に削除 | 推測される余地を、ゼロにする |
| お気に入りの店 | 削除 | 個人の好みの情報 |
| 料理を最初に登録した人 | 空にする | 前の節の「誰が」と同じ理由 |
なぜ「合わなかった」だけ完全に消すのか
匿名化は「誰のものか分からなくする」処理です。
ですが、匿名化したデータでも、ほかの情報と組み合わせると推測できることがあります。
前の節の「引き算 1 回で件数が分かる」も、まさにその一例でした。
「合わなかった」は、漏れた瞬間にサービスの信頼が終わるデータです。
推測される可能性が少しでも残ること自体を、許容できませんでした。
一方、「美味しかった」はもともと公開している情報です。
匿名化して残しても、新しく漏れるものはありません。
漏れたときの重さが違うので、扱いも変えた形です⚖️
匿名化の方法と、そのテスト
匿名化は、「退会したユーザー」を表す固定のアカウントを 1 つ用意して、
退会者の「美味しかった」の持ち主をそこへ付け替える方法にしました。
画面には「退会したユーザー」と表示されます。
退会の処理は 1 つの関数にまとめていて、これも pgTAP でテストしています。
- 「美味しかった」が、退会用のアカウントに付け替わっていること
- 「合わなかった」が、1 件も残っていないこと
- 料理を最初に登録した人の列が、空になっていること
- 退会用のアカウント自体は、削除できないこと(誤って消すと、全員分の匿名化した口コミが消えるため)
最後の 1 つは、守りたいものの逆側の事故を防ぐためのテストです🛡️
まとめ
- RLS は壊れてもエラーが出ない。テストは「壊れたときに本当に落ちるか」から設計する🧪
- 0 件で合格にせず、**見えてよいものが見えること(対照)**も一緒に確かめる
- RLS が守るのは行まで。ほかの列や件数から推測できないかまで考え、列単位の権限で塞ぐ🔐
- 漏れて困るものは、持たない・残さない。それもテストで固定する
「ちゃんと隠せているか」を確かめるテストは、つい「見えないこと」だけを書きがちです。
見えるべきものが見えていることとセットにすると、テストの信頼度がぐっと上がります💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
これまでに公開した記事です。
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
🔗 Next.js 16 で Web Vitals を測って直した実録!loading.tsx で LCP が 2 倍になった罠と関数リージョン
🔗 「全部見せるためのRLS」は書かない。Supabaseで管理画面だけRLSをバイパスした理由
🔗 ディレクトリ構成は「AIへの指示書」になる。Next.js App Routerで自分の設計論を答え合わせした話
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由
次の記事では、このテストをいつ・どこで走らせているかも含めて、GitHub Actions でどこにチェックを置くかを書きました🚀
🔗 CI全緑でも、マージしてはいけないPRがあった。GitHub Actionsは「どこで止めるか」で設計する
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏