はじめに:「RLS を書いた、でもテストしていない」問題
Supabase で RLS を設定してアプリをリリースした後、こんな状況に陥ったことはないだろうか。
- ローカルで動作確認したら問題なかった。なのに本番でデータが取れない(または取れてはいけないデータが取れた)。
- 原因を調べると、「service_role でテストしていたので RLS を実は通過していなかった」「anon と anonymous sign-in ユーザーを混同していた」——という話が多い。
RLS はアプリのセキュリティの根幹だ。「書いた」だけでなく「テストした」状態にしないと、本番障害や情報漏洩リスクと隣り合わせになる。
この記事では、Supabase で RLS を単体テストする実践的な方法を、pgTAP・supabase test db・supabase-test-helpers の役割の整理から始め、SECURITY DEFINER 関数のテスト戦略、よくある落とし穴まで一通り解説する。
筆者は育休中に夫婦向け資産管理アプリ cotty Asset を Supabase で開発した際に、グループ間データ共有の RLS 設計でこの問題に何度もぶつかった。その実体験をベースにしている。
全体像:3つのツールの役割分担
混乱しやすいので最初に整理しておく。
| ツール | 役割 | 一言で |
|---|---|---|
| pgTAP | Postgres 用テストフレームワーク | 書くもの |
| supabase test db | CLI のテストランナー | 回すもの |
| supabase-test-helpers | 認証文脈を簡略化する補助ライブラリ | 楽に書くためのもの |
| SDK | 統合テスト | アプリコードから sign-in して検証 |
pgTAP は results_eq・lives_ok・is などの関数で SQL レベルの検証を書く。
supabase test db は supabase/tests 配下の pgTAP ファイルを pg_prove で実行するだけのランナーだ。テストフレームワーク自体ではない。
supabase-test-helpers(正式名: basejump-supabase_test_helpers)は community-maintained なライブラリで、Supabase 特有の認証文脈(ユーザー作成・ロール切り替えなど)を簡単に作れる。Supabase 公式 docs でも紹介されているが、公式本体の機能ではない点に注意。
記事で紹介するコマンド名は supabase test db(古い記事にある supabase db test ではない)。2026年3月現在の最新 CLI に合わせている。
セットアップ:最小構成でテストを動かす
pgTAP 単体(最小構成)
# ローカル環境を起動
supabase start
# テストファイルを生成
supabase test new hello_world
# テストを実行
supabase test db
生成されたファイル supabase/tests/database/hello_world.test.sql を編集する。
begin;
create extension if not exists pgtap with schema extensions;
select plan(1);
select has_column(
'auth', 'users', 'id',
'auth.users should have id column'
);
select * from finish();
rollback;
各テストファイルはトランザクション内で実行され、終了時に rollback される。これにより本番データを汚染せずに高速に回せる。
supabase-test-helpers を追加する(推奨)
認証ユーザーの切り替えを手書きするのは冗長なので、basejump-supabase_test_helpers を使う。
前提: dbdev(database.dev パッケージマネージャー)のインストール
dbdev.install(...) を呼ぶには dbdev が必要。Supabase ダッシュボードの Database → Extensions から supabase_package_manager を有効にするか、以下の SQL を先に実行する。
-- dbdev のインストール(未導入の場合)
create extension if not exists http with schema extensions;
create extension if not exists pg_tle;
select pgtle.install_extension_version_sql(
'supabase_package_manager',
(select * from http_get('https://supabase.github.io/dbdev/supabase_package_manager.sql'))
);
create extension if not exists supabase_package_manager;
dbdev が不要な場合は、GitHub Releases から直接 .sql ファイルをダウンロードして実行する方法もある(basejump-supabase_test_helpers releases)。
create extension if not exists pgtap with schema extensions;
select dbdev.install('basejump-supabase_test_helpers');
create extension if not exists "basejump-supabase_test_helpers" version '0.0.6';
セットアップ後に使える主な関数:
| 関数 | 用途 |
|---|---|
tests.create_supabase_user('user@example.com') |
テスト用ユーザーを作成 |
tests.authenticate_as('user@example.com') |
そのユーザーとして認証状態にする |
tests.clear_authentication() |
認証状態をクリア(anon 相当) |
tests.get_supabase_uid('user@example.com') |
ユーザーの UUID を取得 |
古い記事で紹介されている as_authenticated_user() / as_anon() は、現行(0.0.6)の API には存在しない。tests.authenticate_as() / tests.clear_authentication() に読み替えること。
auth.uid() のモック方法
RLS テストの核心は「誰として実行しているか」を制御することだ。auth.uid() は内部で request.jwt.claim.sub を参照しているため、テストでは role と JWT claim の両方を設定する必要がある。
SET LOCAL 版(シンプルで読みやすい)
begin;
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
set local request.jwt.claim.role = 'authenticated';
select auth.uid();
-- => 11111111-1111-1111-1111-111111111111
rollback;
set_config 版(動的 SQL や関数化に向く)
begin;
set local role authenticated;
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
select set_config('request.jwt.claim.role', 'authenticated', true);
rollback;
未認証(anon)を明示する
begin;
set local role anon;
select set_config('request.jwt.claim.sub', '', true);
select set_config('request.jwt.claim.role', 'anon', true);
select auth.uid();
-- => null
rollback;
よくあるミス: role だけ設定して JWT claim を入れない
-- ❌ これだけでは auth.uid() が null になる
set local role authenticated;
-- ✅ claim.sub も必ずセットする
set local role authenticated;
set local request.jwt.claim.sub = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
helper 関数として切り出す(テストが増えてきたら)
テストファイルが増えてきたら、認証切り替えを関数化しておくと便利だ。
create or replace function tests.login_as(user_id uuid) returns void language plpgsql as $$
begin
execute 'set local role authenticated';
perform set_config('request.jwt.claim.role', 'authenticated', true);
perform set_config('request.jwt.claim.sub', user_id::text, true);
end;
$$;
create or replace function tests.login_as_anon() returns void language plpgsql as $$
begin
execute 'set local role anon';
perform set_config('request.jwt.claim.role', 'anon', true);
perform set_config('request.jwt.claim.sub', '', true);
end;
$$;
RLS ポリシーを pgTAP でテストする
対象ポリシー
前回の記事「Supabaseで『グループ間データ共有』を安全に実装する設計パターン」で紹介した is_group_member() ヘルパー関数を使ったポリシーをテストする。
create policy "read own items" on public.items for select using (is_group_member(group_id));
最小テストスイート(6ケース)
begin;
create extension if not exists pgtap with schema extensions;
select plan(6);
-- ① スキーマ定義
create table if not exists public.group_members (
group_id uuid not null,
user_id uuid not null,
primary key (group_id, user_id)
);
create table if not exists public.items (
id uuid primary key,
group_id uuid not null,
name text not null
);
alter table public.items enable row level security;
create or replace function public.is_group_member(target_group_id uuid) returns boolean language sql stable as $$
select exists (
select 1 from public.group_members gm
where gm.group_id = target_group_id
and gm.user_id = auth.uid()
);
$$;
drop policy if exists "read own items" on public.items;
create policy "read own items" on public.items for select using (public.is_group_member(group_id));
-- ② テストデータ
select lives_ok($$
insert into public.group_members (group_id, user_id)
values ('00000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111')
$$, 'membership seeded');
select lives_ok($$
insert into public.items (id, group_id, name)
values
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', '00000000-0000-0000-0000-000000000001', 'group1 item'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2', '00000000-0000-0000-0000-000000000002', 'group2 item')
$$, 'items seeded');
-- ③ 自分のグループのデータが読める
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
set local request.jwt.claim.role = 'authenticated';
select results_eq(
$$ select name from public.items order by name $$,
$$ values ('group1 item'::text) $$,
'authenticated user can read items in own group'
);
-- ④ 他グループのデータが読めない
select results_ne(
$$ select name from public.items where group_id = '00000000-0000-0000-0000-000000000002' $$,
$$ values ('group2 item'::text) $$,
'authenticated user cannot read other group items'
);
select is(
(select count(*)::int from public.items), 1,
'only own-group row is visible'
);
-- ⑤ 未認証ユーザーは読めない
reset role;
set local role anon;
select set_config('request.jwt.claim.sub', '', true);
select set_config('request.jwt.claim.role', 'anon', true);
select is(
(select count(*)::int from public.items), 0,
'anon cannot read any items'
);
select * from finish();
rollback;
さらに網羅したいケース
上記 6 ケースに加え、以下も検証しておくと安心だ。
- グループ未所属の authenticated ユーザーでも 0 件になるか
- 複数グループ所属ユーザーは所属先すべて読めるか
-
is_group_member(NULL)が意図せず true にならないか - 同名 policy が複数あるとき OR 結合が期待どおりか
SECURITY DEFINER 関数のテストは何が違うか
通常の RLS テストは「policy が正しいか」を見る。
SECURITY DEFINER 関数のテストは「policy を迂回できる関数が、安全に最小権限で設計されているか」を見る。
SECURITY DEFINER で定義した関数は、呼び出したユーザーではなく関数の定義者(postgres ユーザー)の権限で実行される。これは RLS をバイパスできることを意味する。
SECURITY DEFINER は「RLS の穴」ではなく「制御された窓口」だ。 ただし、その窓口が正しく施錠されているかをテストする必要がある。
確認すべき4点
- 直接テーブル参照は RLS で拒否される
- 関数経由では必要最小限だけ取れる
- anon や不要な role には EXECUTE がない
- search_path が固定されている(Supabase 公式が推奨)
関数定義例
create or replace function private.get_linked_group_items(target_group_id uuid)
returns setof public.items
language sql
security definer
set search_path = '' -- ← 必ず固定する
as $$
select i.* from public.items i
where i.group_id = target_group_id
$$;
-- 権限を絞る
revoke execute on function private.get_linked_group_items(uuid) from public;
grant execute on function private.get_linked_group_items(uuid) to authenticated;
pgTAP テスト(5ケース)
begin;
create extension if not exists pgtap with schema extensions;
select plan(5);
-- RLS を全拒否にして SECURITY DEFINER の bypass を確認しやすくする
alter table public.items enable row level security;
drop policy if exists "read own items" on public.items;
create policy "read own items" on public.items for select using (false);
insert into public.items (id, group_id, name)
values
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', '00000000-0000-0000-0000-000000000001', 'group1 item'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2', '00000000-0000-0000-0000-000000000002', 'group2 item');
-- 1. 通常 SELECT では見えない(RLS が効いている)
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select is(
(select count(*)::int from public.items), 0,
'plain select is blocked by RLS'
);
-- 2. SECURITY DEFINER 関数経由では取得できる
select results_eq(
$$ select name from private.get_linked_group_items('00000000-0000-0000-0000-000000000001') $$,
$$ values ('group1 item'::text) $$,
'security definer function can read underlying table'
);
-- 3. 返し過ぎていない(別グループのデータを返さない)
select results_ne(
$$ select name from private.get_linked_group_items('00000000-0000-0000-0000-000000000002') $$,
$$ values ('group1 item'::text) $$,
'function returns only requested group rows'
);
-- 4. anon は EXECUTE 不可
reset role;
set local role anon;
select throws_ok(
$$ select * from private.get_linked_group_items('00000000-0000-0000-0000-000000000001') $$,
'42501',
null,
'anon cannot execute the security definer function'
);
-- 5. search_path が固定されているかメタデータで確認
select ok(
exists (
select 1 from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'private'
and p.proname = 'get_linked_group_items'
and exists (
select 1 from unnest(p.proconfig) as cfg
where cfg like 'search_path=%'
)
),
'function definition fixes search_path'
);
select * from finish();
rollback;
よくある落とし穴 10 選
「テストは通るのに本番で意図しない挙動になる」パターンを整理した。
1. service_role で確認して RLS を実は通っていない
service_role は BYPASSRLS を持つため、policy が壊れていても全件読める。RLS のテストは必ず authenticated / anon role で行うこと。
-- ❌ service_role では RLS を通過しない
set local role service_role;
-- ✅ 本番の API リクエスト相当の role を使う
set local role authenticated;
典型的な事故: ローカルで service_role クライアントで確認 → 全件見える → 本番の authenticated では RLS に阻まれて見えない
2. role だけ設定して JWT claim を入れていない
auth.uid() は request.jwt.claim.sub を参照する。role だけでは不十分だ。
-- ❌ auth.uid() が null になる
set local role authenticated;
-- ✅ claim.sub も必ずセットする
set local role authenticated;
set local request.jwt.claim.sub = 'xxxxxxxx-...';
3. anon と「anonymous sign-in ユーザー」を混同する
Supabase の anonymous sign-in ユーザーは Postgres role として authenticated だ。TO authenticated の policy は通ってしまう。
| 種別 | Postgres role | auth.uid() |
|---|---|---|
| 未ログイン | anon | null |
| anonymous sign-in | authenticated | UUID あり |
| 通常ログイン | authenticated | UUID あり |
anon だけを見て「未ログインも弾けている」と判断すると、本番の anonymous user が想定外に通るケースが起きる。
4. TO authenticated を付けずに USING だけで役割分離したつもり
-- △ role 制約が条件式に埋もれる
create policy "read own" on items for select using (auth.uid() = user_id);
-- ✅ role を明示する
create policy "read own" on items for select to authenticated using (auth.uid() = user_id);
TO authenticated を省略すると、anon role にも policy が適用されうる。後から policy を追加したときに意図せず穴が開きやすい。
5. 複数 policy の合成ルールを見落とす
Postgres の permissive policy は OR で結合される。後から追加した 1 本の permissive policy が、既存の絞り込みを実質的に広げることがある。
テストが単一 policy 前提だと、本番で policy が増えた瞬間に挙動が変わる。policy を追加したら必ずテストを更新すること。
6. SELECT だけテストして WITH CHECK を見ていない
| 句 | 意味 |
|---|---|
| USING | 「見える行」(SELECT・UPDATE 対象行・DELETE 対象行) |
| WITH CHECK | 「作れる/更新後に許される行」(INSERT・UPDATE 後の行) |
「読めないが書ける」または「読めるが更新できない」という事故は WITH CHECK の見落としで起きる。
7. SECURITY DEFINER 関数を通常 RLS と同じ感覚で見る
SECURITY DEFINER 関数は基底テーブルの RLS を迂回しうる。「テーブル直参照のテストが通る ≠ 関数経由も安全」だ。前章のテストで別途検証すること。
8. テストでテーブル owner や高権限 role を使っている
table owner や BYPASSRLS 権限は RLS を迂回する。開発用 SQL で owner のまま確認して「見える」と判断し、本番の API 経由で見えない——というパターンがある。
9. migration テーブルで RLS 有効化を忘れる
Dashboard で作成したテーブルは RLS がデフォルトで有効になるが、SQL migration で作成したテーブルは自動で有効にならない。
-- migration ファイルに必ず入れる
alter table public.items enable row level security;
10. raw_user_meta_data を認可条件に使っている
raw_user_meta_data はユーザー自身が更新できる値だ。認可条件に使うと権限昇格のリスクがある。
-- ❌ ユーザーが書き換えられる
using (auth.jwt() ->> 'raw_user_meta_data' = 'admin')
-- ✅ サーバーサイドでのみ書き込める値を使う
using (auth.jwt() -> 'app_metadata' ->> 'role' = 'admin')
まとめ:設計チェックリスト
テスト環境と本番環境で RLS がズレる最大要因は、policy の違いではなく「誰として SQL が実行されているか」の違いである。
RLS のテストを書くときに確認してほしい項目をまとめた。
セットアップ
- pgTAP を extension として追加している
-
テストファイルは
begin; ... rollback;で囲んでいる -
supabase test dbで実行できることを確認した
認証モック
-
set local role authenticated;とrequest.jwt.claim.subを両方セットしている - anon と authenticated(anonymous sign-in 含む)を別々にテストしている
- service_role では RLS テストをしていない
ポリシーの検証
- SELECT(USING)だけでなく INSERT/UPDATE(WITH CHECK)もテストしている
- 複数 policy を追加した後に合成結果をテストしている
-
TO authenticatedを明示して policy を定義している
SECURITY DEFINER 関数
- 直接テーブル参照が RLS で拒否されることを確認した
- 関数経由では必要最小限のデータだけ返ることを確認した
- anon や不要な role に EXECUTE が付いていない
-
set search_path = ''を関数定義に入れている
運用
-
migration テーブルに
alter table ... enable row level security;を入れている - 認可条件に raw_user_meta_data ではなく raw_app_meta_data を使っている
この記事で紹介した設計を実際に使っているアプリ
この記事で紹介した RLS テスト戦略は、育休中に開発した夫婦向け資産管理アプリ cotty Asset の実装をベースにしている。夫婦それぞれのグループを分けて管理しながら、互いの資産状況をリアルタイムで確認できる。RLS と SECURITY DEFINER 関数の組み合わせで、パートナーに見せたくない資産の非表示制御も実現している。