1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabase RLS を単体テストする実践ガイド——pgTAP・SECURITY DEFINER まで

1
Posted at

はじめに:「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_eqlives_okis などの関数で 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点

  1. 直接テーブル参照は RLS で拒否される
  2. 関数経由では必要最小限だけ取れる
  3. anon や不要な role には EXECUTE がない
  4. 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 関数の組み合わせで、パートナーに見せたくない資産の非表示制御も実現している。

cotty Asset - 夫婦の資産管理アプリ(iOS)

1
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?