1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RLSが壊れてもテストは緑のまま。漏れても気づけない非公開データを、pgTAPで「落ちるテスト」にする

1
Last updated at Posted at 2026-10-07

はじめに

🤔「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)

参考になれば幸いです🙏

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?