Supabaseを使った個人開発で、Row Level Security(RLS)を設定しました。
たとえば「自分のデータだけ読める」というルールなら、こんなPolicyです。
create policy "Users can view own trips"
on public.trips
for select
to authenticated
using (auth.uid() = owner_id);
SQLを見る限り、かなり分かりやすいです。
でも開発を進めていて、ひとつ気になることがありました。
「このPolicy、本当に期待どおりに効いていることをどうやって確認するんだろう?」
画面から自分のデータが見えることは確認できます。
一方で、
- 他のユーザーからは本当に見えないのか
- 未認証ユーザーからは見えないのか
- 他人のデータをUPDATE / DELETEできないか
-
owner_idだけ自分にして、他人の親データへ紐付けられないか
まで毎回手動で確認するのは大変です。
そこで、ローカルSupabaseとpgTAPを使って、
owner / other owner / anon の3つの立場からRLSの振る舞いをテストする
ようにしました。
この記事では、実際に個人開発で使っているテストを簡略化しながら、その考え方を紹介します。
前提
Supabase CLIでは、Supabaseの各サービスをローカル環境で動かせます。
ローカル環境を使う場合は、Supabase CLIに加えてDocker DesktopなどのDocker互換runtimeが必要です。
プロジェクトを初期化すると、
npx supabase init
supabase/ ディレクトリが作られます。
ローカル環境は、
npx supabase start
で起動できます。
Supabase CLIにはPostgresのテスト機能もあり、
npx supabase test db
で supabase/tests/ 以下のpgTAPテストを実行できます。
今回はこれを使ってRLSをテストします。
今回テストしたいアクセス制御
作っているアプリでは、Supabase Authでログインし、ユーザーごとに旅行や記録、写真などを保存しています。
基本的なルールはシンプルです。
ログインしている本人だけが、自分のデータを操作できる。
たとえば trips テーブルには owner_id があり、RLSを有効にしています。
alter table public.trips enable row level security;
SELECTは本人だけです。
create policy "Users can view own trips"
on public.trips
for select
to authenticated
using (auth.uid() = owner_id);
INSERTも本人だけ。
create policy "Users can insert own trips"
on public.trips
for insert
to authenticated
with check (auth.uid() = owner_id);
UPDATEでは、既存行へのアクセスだけでなく更新後の値もチェックします。
create policy "Users can update own trips"
on public.trips
for update
to authenticated
using (auth.uid() = owner_id)
with check (auth.uid() = owner_id);
Policy自体はそれほど複雑ではありません。
ただ、
「正しそうなPolicyが書いてある」ことと「期待するアクセス制御になっている」ことは別です。
そこでPolicyのSQLを確認するだけでなく、実際に異なるユーザーとしてSQLを実行してみることにしました。
owner / other owner / anon の3方向からテストする
テストするのは大きく3つの立場です。
owner
自分のデータを操作するユーザー
other owner
別ユーザーのデータへアクセスしようとするユーザー
anon
未認証ユーザー
正常系だけではなく、
「許可されてはいけない操作が本当に拒否されるか」
まで確認するのがポイントです。
テスト用ユーザーとデータを作る
まず、テストの中だけで使用するユーザーを2人作ります。
begin;
insert into auth.users (id, email)
values
(
'11111111-1111-1111-1111-111111111111',
'owner@local.test'
),
(
'22222222-2222-2222-2222-222222222222',
'other@local.test'
);
それぞれが所有するTripも作ります。
insert into public.trips
(id, owner_id, name, starts_on, ends_on)
values
(
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'11111111-1111-1111-1111-111111111111',
'Owner trip',
'2026-11-30',
'2026-12-01'
),
(
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
'22222222-2222-2222-2222-222222222222',
'Other trip',
'2026-11-30',
'2026-12-01'
);
これで同じDBに、
ownerのデータ
other ownerのデータ
が存在する状態になります。
ownerとして実行する
次にPostgresのroleを authenticated にします。
今回のテストでは、JWT claimの sub にテストユーザーのUUIDを設定して、auth.uid() が返すユーザーを切り替えています。
set local role authenticated;
set local request.jwt.claim.sub =
'11111111-1111-1111-1111-111111111111';
ここからはownerとしてSQLを実行します。
まずSELECTです。
select results_eq(
'select count(*) from public.trips',
array[1::bigint],
'owner can select only own trips'
);
DBには2人分のTripがあります。
しかしownerから見えるのは、自分が所有する1件だけであることを確認します。
次にINSERT。
select lives_ok(
$$
insert into public.trips
(owner_id, name, starts_on, ends_on)
values
(
'11111111-1111-1111-1111-111111111111',
'New owner trip',
'2026-11-30',
'2026-12-01'
)
$$,
'owner can insert own trip'
);
同じようにUPDATE / DELETEもテストします。
ここで確認しているのは、
「SELECT Policyが存在する」
ではなく、
「ownerとしてSELECTしたら、自分のデータだけ取得できる」
という振る舞いです。
other ownerになって、やってはいけない操作を試す
次はJWT claimを変更します。
set local request.jwt.claim.sub =
'22222222-2222-2222-2222-222222222222';
これでother ownerとして実行できます。
ownerのTripを取得してみます。
select results_eq(
$$
select count(*)
from public.trips
where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
$$,
array[0::bigint],
'other owner cannot select owner trip'
);
期待値は0件です。
UPDATEも試します。
select results_eq(
$$
update public.trips
set name = 'Hacked'
where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
returning id
$$,
array[]::uuid[],
'other owner cannot update owner trip'
);
DELETEも同様にテストします。
RLSのテストでは、
ownerで正常系を確認するだけでなく、other ownerで禁止したい操作を実際に試す
ことを意識しています。
「自分のデータなら何でもOK」ではない
もうひとつ重要だったのが、関連データです。
たとえば、
Trip
└── Record
という関係があるとします。
Record自身の owner_id が自分なら安全……とは限りません。
もし、
自分のRecord
↓
他人のTrip
という関連を作れてしまったら困ります。
そこでRecordのINSERT Policyでは、owner_idだけでなく親のTripも確認しています。
with check (
auth.uid() = owner_id
and (
trip_id is null
or exists (
select 1
from public.trips
where id = trip_id
and owner_id = auth.uid()
)
)
);
テスト側では、実際にcross-ownerの関連を作ろうとします。
select throws_ok(
$$
insert into public.records
(owner_id, trip_id, title)
values
(
'22222222-2222-2222-2222-222222222222',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'Cross owner record'
)
$$,
'42501',
null,
'owner cannot attach a record to another owner trip'
);
このケースは、
「他人のRecordが見えない」だけをテストしていても拾えません。
関連を持つデータでは、
自分のデータから他人のリソースへ接続できないか
もテスト対象にしています。
anonから見えないことも確認する
最後は未認証ユーザーです。
reset role;
set local role anon;
各テーブルをSELECTします。
select results_eq(
'select count(*) from public.trips',
array[0::bigint],
'anon cannot select trips'
);
RecordやPhotoなどについても同じように確認します。
これで、
owner
→ 自分のデータを操作できる
other owner
→ 他人のデータを操作できない
anon
→ 保護データを取得できない
という3方向からアクセス制御を確認できます。
ローカルSupabaseで繰り返し実行する
実際のプロジェクトでは、テストを
supabase/tests/rls.test.sql
に置いています。
package.json には次のscriptを用意しました。
{
"scripts": {
"db:start": "supabase start",
"db:reset": "supabase db reset --local",
"db:test": "supabase test db --local supabase/tests"
}
}
そのため、
npm run db:start
npm run db:reset
npm run db:test
で、
- ローカルSupabaseを起動
- migrationからDBを作り直す
- pgTAPを実行
という流れを再現できます。
Supabaseのローカル開発環境では、db reset によってローカルDBを作り直し、migrationを順番に再適用できます。
つまりRLS単体だけでなく、
repositoryに入っているmigrationを最初から適用しても、期待するアクセス制御になるか
まで確認できます。
テスト自体はtransactionで囲みます。
begin;
/* fixtures */
/* assertions */
select * from finish();
rollback;
テスト用ユーザーやfixtureを作っても、最後にrollbackします。
Storageも同じようにテストしようとして失敗した
ここまでは比較的きれいに進みました。
ただ、一度失敗したところがあります。
Supabase Storageです。
最初は storage.objects に対しても、アプリケーションテーブルと同じ感覚で直接INSERT / UPDATE / DELETEするテストを書いていました。
ところが実際にローカルDBで実行すると、当時用意していた43個のassertionのうち36個まで進んだところで失敗しました。
調べてみると、ここは考え方が違いました。
Supabase Storageでは、Postgresの storage schemaにbucketやobjectのmetadataが保存されています。
しかし、実際のファイルそのものは別のstorage providerに存在します。
そのためSupabaseの公式ドキュメントでも、StorageのレコードはSQL上ではread-onlyとして扱い、upload / copy / move / deleteなどの操作はStorage APIを経由するよう案内されています。
metadataだけSQLで削除しても、実体のobjectまで同じように削除されるわけではありません。
つまり、
Storageの内部テーブルを直接変更するテストは、本番でアプリが行う操作を再現していませんでした。
Storageは検証方法を分けた
そこでStorageについては方針を変更しました。
アプリケーションテーブルは、
実際のroleを切り替える
↓
SQLを実行する
↓
期待する結果になるか確認
というbehavior testを続けます。
一方、StorageについてはpgTAPからobjectを直接変更せず、まず期待するPolicyが存在することをcatalogから確認します。
select policies_are(
'storage',
'objects',
array[
'Users can view own storage photos',
'Users can upload own storage photos',
'Users can update own storage photos',
'Users can delete own storage photos'
],
'record-photos has exactly the expected Storage RLS policies'
);
対象roleも確認します。
select policy_roles_are(
'storage',
'objects',
'Users can view own storage photos',
array['authenticated'::name],
'Storage view policy is authenticated-only'
);
実際のStorage APIを通したupload / deleteなどは、API経由の別の検証として扱います。
結果として、
Application DB
↓
pgTAPによるbehavior test
Storage Policy
↓
pgTAPによるpolicy catalog assertion
Storage API
↓
API経由で実際の振る舞いを確認
と責務を分けました。
修正後にローカルDB Gateを再実行すると、当時用意していた43個のpgTAP assertionはすべてPASSしました。
「Policyをテストする」より「アクセス制御をテストする」
今回やってみて一番変わったのは、RLSテストに対する考え方でした。
最初は、
RLSを有効にして、
auth.uid() = owner_idのPolicyを書けばOK
くらいに考えていました。
でも実際にテストを書いてみると、本当に確認したかったのはPolicyのSQLそのものではありませんでした。
確認したかったのは、
ownerなら何ができる?
other ownerなら何ができない?
anonなら何が見えない?
自分のデータを
他人のリソースへ紐付けられない?
というアクセス制御の振る舞いでした。
RLSでは「正常に取得できた」だけでは、安全性を十分に確認できません。
許可される操作だけでなく、
拒否されるべき操作を実際に試す。
今は新しいRLS対象を追加するとき、
owner / other owner / anonなら、それぞれどう振る舞うべきか?
をテストケースとして考えるようにしています。
そしてStorageのように、同じテスト方法をそのまま適用できないものがあれば、その境界も分ける。
RLSを「設定したつもり」で終わらせず、migrationと一緒にローカル環境で繰り返し検証できるようになったことが、pgTAPを導入して一番良かったところでした。