IT技術ブログ

Supabase(PostgreSQL)のRLSで、予約と顧客のデータを「自分の店の分だけ」見せる実装。店舗ごとの分離、auth.uid()とロール、ポリシーの書き方、service_roleキーの扱い、抜けを見つけるテスト

SupabaseのRLSで予約と顧客の情報を店舗ごとに分ける実装を、PGliteで動かして確かめました。所属表とauth.uid()のポリシー、他店への書き込みを止める条件、service_roleキーの扱い、付け忘れを見つけるテストまで。

この記事の結論:Supabaseで予約や顧客のデータを店舗ごとに分けるときは、RLSを3つの手順で組みます。①すべての表に店舗の列(tenant_id)を持たせてRLSを有効にし、②「ログインした人(auth.uid())が所属している店舗」を所属の表から引く関数を作り、③読み取り・登録・更新・削除ごとに、その関数を使ったポリシーをto authenticatedつきで書きます。RLSを素通りするservice_role(シークレットキー)はブラウザに出さず、RLSの付け忘れと他店のデータが見えないことを、テストで毎回確かめます。

この記事のSQLとテストは、手元でPGlite(WebAssembly版のPostgreSQL)を使って実際に流したものです。本物のSupabaseのプロジェクトにはつないでいません。Supabaseが1回のリクエストごとに裏で行っている「ロールの切り替え」と「JWTの中身を渡す処理」を、SQLで同じように再現して確かめました。どこまでが再現で、どこが本物と違うかは、後半の「PGliteで再現したことと、本物のSupabaseとの違い」に書いています。

店舗ごとの分離は、所属の表とauth.uid()を使うポリシーで作る

テナント(店舗)ごとの分離は、「この人はどの店舗に所属しているか」をデータベースが自分で判断できるようにすると、画面やAPIの作りに左右されなくなります。そのために、ログインした人の利用者IDを返すauth.uid()と、利用者と店舗を結ぶ所属の表(memberships)を組み合わせます。

この記事で使う表は次の4つです。予約管理の画面を、複数の店舗が1つのデータベースで使う形を想定しています。

表 中身 店舗の列
tenants 店舗 idそのもの
memberships 利用者がどの店舗に、どの役割(owner・staff)で所属しているか tenant_id
customers 顧客(名前・電話番号) tenant_id
reservations 予約 tenant_id

ポイントは、顧客にも予約にもtenant_idを必ず持たせることです。「予約の店舗は、顧客の店舗を見れば分かる」という作りにすると、予約のポリシーが顧客の表を毎回たどることになり、条件が複雑になって抜けが出やすくなります。少し冗長でも、守りたい行そのものに店舗の列を置きます。

auth.uid()は、APIがリクエストごとに入れるJWTの中身を読んでいる

auth.uid()は、ログインした人のトークン(JWT)のsubを読む関数で、データベースの外から魔法のように値が来るわけではありません。SupabaseのAPI(PostgREST)は、リクエストごとにトランザクションを始め、JWTのroleに合わせてデータベースのロールを切り替え、JWTの中身をrequest.jwt.claimsという設定に入れてからSQLを流します。

Supabase Authのソースにあるマイグレーションでは、auth.uid()は次のように定義されています。この記事の再現でも、同じ定義を使いました。

sql
create function auth.uid() returns uuid language sql stable as $$
  select coalesce(
    nullif(current_setting('request.jwt.claim.sub', true), ''),
    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
  )::uuid
$$;

ここから分かることが2つあります。1つは、ログインしていないリクエストではsubが無いので、auth.uid()はnullになることです。Supabaseのドキュメントにも同じことが書かれています。もう1つは、ロールが3つに分かれることです。ログインしていない人はanon、ログインした人はauthenticated、シークレットキー(旧来のservice_roleキー)を使った処理はservice_roleとして動きます。service_roleには、RLSを素通りするBYPASSRLSという属性が付いています。

再現では、1回のリクエストを次の手順でまねました。トランザクションの中だけでロールと設定を変え、最後に取り消すので、テストどうしが影響し合いません。

js
async function as(role, sub, fn) {
  await db.exec('begin');
  try {
    await db.query(`set local role ${role}`);
    const claims = JSON.stringify(sub ? { sub, role } : { role });
    await db.query(`select set_config('request.jwt.claims', $1, true)`, [claims]);
    return await fn();
  } finally {
    await db.exec('rollback');
  }
}

ポリシーは、読み取り・登録・更新・削除ごとに分け、to authenticatedを付ける

ポリシーは操作ごとに分けて書き、読み取りと削除はUSING、登録はWITH CHECK、更新はその両方で条件を書きます。どのロールに効かせるかをto authenticatedで必ず書くと、ログインしていない人には最初から何も通りません。

予約の表のポリシーは次のとおりです(全体のSQLは後半の「再現に使ったコード」に載せています)。

sql
alter table public.reservations enable row level security;

create policy "所属している店の予約を見る" on public.reservations
  for select to authenticated
  using (tenant_id in (select private.my_tenant_ids()));
create policy "所属している店に予約を入れる" on public.reservations
  for insert to authenticated
  with check (tenant_id in (select private.my_tenant_ids()));
create policy "所属している店の予約を直す" on public.reservations
  for update to authenticated
  using (tenant_id in (select private.my_tenant_ids()))
  with check (tenant_id in (select private.my_tenant_ids()));
create policy "予約の削除は店のオーナーだけ" on public.reservations
  for delete to authenticated
  using ((select private.is_owner(tenant_id)));

書くときの注意は4つです。

  • 更新にはUSINGとWITH CHECKの両方を書く。 USINGは「どの行を直してよいか」、WITH CHECKは「直したあとの行が条件に合うか」を判定します。WITH CHECKが無いと、自店の予約のtenant_idを他店に書き換える更新を止められません。
  • 更新には読み取りのポリシーも要る。 PostgreSQLのドキュメントでは、WHEREで行を探すUPDATEには、更新のポリシーに加えて読み取りのポリシーも適用されると説明されています。Supabaseのドキュメントにも、更新するには対応する読み取りのポリシーが必要だと書かれています。
  • auth.uid()や関数は(select ...)で包む。 Supabaseのドキュメントでは、こう書くとPostgreSQLが1つの文の中で結果を使い回せると説明されています。行ごとに関数を呼び直さないための書き方です。
  • ポリシーで絞り込む列に索引を張る。 tenant_idとmemberships.user_idに索引を作っています。これもSupabaseのドキュメントで勧められている点です。

所属を調べる関数は、APIに出していないprivateスキーマに置き、security definerで作りました。関数の中で所属の表を読むときに、所属の表自身のRLSを毎回通らずに済み、ポリシーが別の表のポリシーを呼ぶ入れ子にもなりません。set search_path = ''で、関数の中の表の名前をすべてスキーマつきで書くようにしています。

sql
create function private.my_tenant_ids() returns setof uuid
language sql stable security definer set search_path = '' as $$
  select m.tenant_id from public.memberships m where m.user_id = (select auth.uid())
$$;

店舗のIDをJWTに入れて判定する方法もあります。その場合は、利用者が自分で書き換えられるuser_metadataではなく、利用者が変えられないapp_metadataを使うよう、Supabaseのドキュメントに書かれています。ただ、JWTに入れた値はトークンが発行し直されるまで古いまま残るので、退職や異動ですぐに見られなくしたい業務では、所属の表を引く方法を選びました。権限を役割ごとの表で決めておく考え方は、業務システムのログイン・権限・操作ログの記事に書いています。

他店のデータは、読むときは「見えない」、書くときは「エラー」になる

RLSの条件に合わない行は、読み取りと削除ではエラーにならずに見えなくなり、登録と更新ではエラーになります。この違いを知らないと、「削除ボタンを押したのに何も起きない」という不具合を、権限の問題だと気づけません。

手元で流したテストの結果は次のとおりです。店舗Aのスタッフ、店舗Bのオーナー、ログインしていない人、service_roleの4つの立場で、同じ表に問い合わせています。

ok 1 - 店舗Aのスタッフには、店舗Aの予約2件だけが見える
ok 2 - 店舗Bのオーナーには、店舗Bの予約1件だけが見える
ok 3 - ログインしていない(anon)と、予約も顧客も0件
ok 4 - IDを直接指定しても、他店の顧客は0件(エラーではなく見えないだけ)
   → new row violates row-level security policy for table "reservations"
ok 5 - 他店に予約を入れようとすると、WITH CHECK で止まる
   → new row violates row-level security policy for table "reservations"
ok 6 - 自店の予約を他店へ付け替える更新も止まる
ok 7 - 他店の予約を消そうとしても、0件の削除で終わる(エラーにならない)
ok 8 - スタッフは自店の予約も消せない。オーナーは消せる
   → insert or update on table "reservations" violates foreign key constraint "reservations_tenant_id_customer_id_fkey"
ok 9 - 自店の予約に他店の顧客をつなごうとすると、複合の外部キーで止まる
ok 10 - service_role はポリシーを素通りして3件すべて見える
┌─────────┬────────────────┬──────┬──────────┐
│ (index) │ table          │ rls  │ policies │
├─────────┼────────────────┼──────┼──────────┤
│ 0       │ 'customers'    │ true │ 3        │
│ 1       │ 'memberships'  │ true │ 1        │
│ 2       │ 'reservations' │ true │ 4        │
│ 3       │ 'tenants'      │ true │ 1        │
└─────────┴────────────────┴──────┴──────────┘
ok 11 - public の全表で RLS が有効
   RLS が無効の表 → [ 'reservation_notes' ]
   ログインなしで読めたメモ → [ 'Aのメモ', 'Bのメモ' ]
ok 12 - わざと RLS を付け忘れた表を、検査が見つける

12 件すべて通りました

立場と操作ごとに表にまとめます。

立場と操作 結果
店舗Aのスタッフが予約を一覧する 店舗Aの2件だけ
ログインしていない人が予約・顧客を一覧する 0件
店舗Aのスタッフが店舗Bの顧客をIDで直接引く 0件(エラーにはならない)
店舗Aのスタッフが店舗Bに予約を入れる エラー(new row violates row-level security policy)
店舗Aのスタッフが自店の予約を店舗Bへ付け替える エラー(同上)
店舗Aのスタッフが店舗Bの予約を消す エラーにならず、0件の削除
店舗Bのオーナーが店舗Aの予約を消す エラーにならず、0件の削除(削除できる立場でも他店の行は見えない)
店舗Aのスタッフが自店の予約を消す 0件の削除(オーナーだけに許可しているため)
店舗Bのオーナーが自店の予約を消す 1件の削除
service_roleで一覧する 全店舗の3件

画面やAPIの側では、削除や更新の件数が0件だったときに「成功しました」と出さず、失敗として扱うようにします。Supabaseのクライアントから消す場合も、消えた行を返してもらい、件数を確かめると取り違えに気づけます。

RLSは外部キーの確認を止めないので、店舗の列を含めた外部キーにする

RLSで他店の顧客が見えなくなっていても、他店の顧客のIDを知っていれば、その顧客を自店の予約につなげてしまう作りがあります。PostgreSQLのドキュメントでは、一意制約や外部キーのような整合性の確認は、常にRLSを素通りすると説明されています。

比較のため、予約から顧客への外部キーをcustomer_idだけにした場合を試しました。

店舗Aのスタッフが、店舗Bの顧客IDで予約を登録 → 1 件登録できた
そのスタッフから店舗Bの顧客は見えるか → 0 件
current_user = postgres
表の持ち主で読んだ予約 → 3 件

店舗Aのスタッフからは店舗Bの顧客は見えないのに、その顧客のIDを使った予約は登録できてしまいました。外部キーの確認が、見えない行も含めて行われるためです。そこで、顧客の表にunique (tenant_id, id)を付け、予約からは(tenant_id, customer_id)の組で外部キーを張りました。こうすると、予約の店舗と顧客の店舗が違う組み合わせはデータベースが拒みます。テストの9番目が、その確認です。

同じ結果の最後の2行は、管理者の接続についての注意です。PGliteの既定の利用者postgresは、全権限を持つ管理者(スーパーユーザー)で、BYPASSRLSも持っています。この接続で数えると、ポリシーに関係なく3件すべてが見えます。PostgreSQLのドキュメントでも、スーパーユーザーとBYPASSRLSを持つロールは常にRLSを素通りし、表の持ち主も通常は素通りすると書かれています。管理者の接続で「データが見えた」と確かめても、利用者からの見え方を確かめたことにはなりません。テストは必ずロールを切り替えて行います。

service_roleキーは画面に出さず、ビルドの成果物に混ざっていないか調べる

RLSを素通りするservice_role(シークレットキー)は、ブラウザやスマホのアプリに届く場所に置かないことが前提です。Supabaseのドキュメントでは、公開してよい鍵(sb_publishable_で始まる鍵。旧来のanonキー)と、バックエンドだけで使う鍵(sb_secret_で始まる鍵。旧来のservice_roleキー)が分けて説明されています。

画面から使ってよいのは公開してよい鍵だけです。RLSが守りの中心になるのは、この鍵が誰にでも見えてしまう前提だからです。一方、シークレットキーを使ってよいのは、サーバー、Edge Functions、定期実行の処理のように、利用者の手元に届かない場所です。ドキュメントには、シークレットキーをブラウザから使うとUser-Agentを見て401を返す仕組みがあると書かれていますが、旧来のservice_roleキーや、ブラウザ以外のアプリにはこの仕組みが効くとは限らないので、頼りにはしません。

もう1つ大事なのは、サーバーでシークレットキーを使い、利用者のトークンを付けずに問い合わせた時点で、RLSの守りが消えることです。管理用の処理で全店舗を対象にするのでなければ、サーバーの側でも「どの店舗のデータを扱うか」を必ずコードで絞ります。利用者の代わりに処理するだけなら、サーバーでも利用者のトークンを使って問い合わせ、RLSを効かせたままにする方が安全です。

鍵がうっかり画面側のコードに入っていないかは、ビルドの成果物を調べると機械的に分かります。次のスクリプトは、出力したファイルの中からsb_secret_で始まる文字列と、中身のroleがservice_roleになっているJWTを探し、見つかったら終了コード1で止めます。

scan-secrets.mjsjs
// ビルドの出力(ブラウザに配るファイル)に、Supabase の秘密の鍵が混ざっていないか調べる
// 使い方: node scan-secrets.mjs <ディレクトリ>   見つかったら終了コード1(CI を止める)
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

const dir = process.argv[2] ?? 'dist';
const TEXT = /\.(js|mjs|cjs|html|css|json|map|txt)$/;
const findings = [];

function* walk(d) {
  for (const name of readdirSync(d)) {
    const p = join(d, name);
    if (statSync(p).isDirectory()) yield* walk(p);
    else if (TEXT.test(name)) yield p;
  }
}

for (const file of walk(dir)) {
  const text = readFileSync(file, 'utf8');
  // 新しい形式の秘密の鍵
  for (const m of text.matchAll(/sb_secret_[A-Za-z0-9_-]+/g)) {
    findings.push({ file, kind: 'sb_secret_ の鍵', head: m[0].slice(0, 14) + '…' });
  }
  // 旧形式(JWT)の鍵:中身の role が service_role なら秘密の鍵
  for (const m of text.matchAll(/eyJ[A-Za-z0-9_-]+\.(eyJ[A-Za-z0-9_-]+)\.[A-Za-z0-9_-]+/g)) {
    try {
      const payload = JSON.parse(Buffer.from(m[1], 'base64url').toString('utf8'));
      if (payload.role === 'service_role') {
        findings.push({ file, kind: 'role が service_role の JWT', head: m[0].slice(0, 14) + '…' });
      }
    } catch { /* JWT の形をした別の文字列は無視 */ }
  }
}

if (findings.length) {
  console.table(findings);
  console.error(`秘密の鍵らしき文字列が ${findings.length} 件あります。公開しないでください。`);
  process.exit(1);
}
console.log(`${dir}: 秘密の鍵は見つかりませんでした`);

ダミーの鍵を入れた2つのフォルダで試した結果です。公開してよい鍵だけのフォルダは通り、シークレットキーとservice_roleのJWTを入れたフォルダは止まりました。

$ node scan-secrets.mjs fake-ok
fake-ok: 秘密の鍵は見つかりませんでした
exit=0
$ node scan-secrets.mjs fake-ng
┌─────────┬─────────────────────────┬───────────────────────────────┬───────────────────┐
│ (index) │ file                    │ kind                          │ head              │
├─────────┼─────────────────────────┼───────────────────────────────┼───────────────────┤
│ 0       │ 'fake-ng/assets/app.js' │ 'sb_secret_ の鍵'             │ 'sb_secret_DUMM…' │
│ 1       │ 'fake-ng/assets/app.js' │ 'role が service_role の JWT' │ 'eyJhbGciOiJIUz…' │
└─────────┴─────────────────────────┴───────────────────────────────┴───────────────────┘
秘密の鍵らしき文字列が 2 件あります。公開しないでください。
exit=1

このスクリプトが見つけられるのは、鍵の形がそのまま残っている場合だけです。文字列を分割したり変換したりして埋め込まれた鍵は見つけられません。環境変数の名前に、画面側へ出す印(Next.jsならNEXT_PUBLIC_)をシークレットキーに付けない、という決まりと合わせて使います。

ポリシーの抜けは、RLSの付け忘れと「他店が見えないこと」の2つをテストする

ポリシーの抜けで一番起きやすいのは、あとから足した表でRLSを有効にし忘れることです。RLSが無効な表は、anonに表の権限が付いていれば、公開してよい鍵を持つ人ならログインしていなくても読めます(Supabaseのドキュメントでは、既存のプロジェクトではpublicの新しい表に3つのロールの権限が自動で付くと説明されています)。そこで、publicスキーマの全部の表についてRLSが有効かをSQLで調べ、無効な表があればテストを落とします。

sql
select c.relname as table,
       c.relrowsecurity as rls,
       count(p.polname)::int as policies
from pg_class c
join pg_namespace ns on ns.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where ns.nspname = 'public' and c.relkind = 'r'
group by c.relname, c.relrowsecurity
order by c.relname;

この検査が本当に抜けを見つけるかを確かめるため、テストの最後で、RLSを付けずに予約のメモの表(reservation_notes)をわざと作りました。検査はこの表を見つけ、ログインしていない立場で、店舗Aと店舗Bの両方のメモが読めることも確かめられました(上の結果のok 12)。

テストの組み立ては、次の2段にしています。

  1. 表の検査:publicスキーマの全部の表でRLSが有効か。ポリシーの数も一覧に出し、0の表は意図したものかを人が見る。
  2. 立場ごとの検査:店舗Aの人、店舗Bの人、ログインしていない人、service_roleのそれぞれで、読める件数、書き込みの成否、削除の件数を確かめる。表を足したら、その表の行もここに足す。

この2つを、マイグレーションを入れるたびに流すと、「新しい表にポリシーを書き忘れた」「更新のWITH CHECKを書き忘れた」という抜けを、本番に出る前に見つけられます。

PGliteで再現したことと、本物のSupabaseとの違い

この記事で確かめたのは、PostgreSQLのRLSとポリシーの動き、auth.uid()の定義、ロールの切り替えまでです。Supabaseの本物の仕組みとは、次の点が違います。

  • JWTの検証はしていない。 本物では、APIがJWTの署名を確かめてからrequest.jwt.claimsに入れます。再現では、テストのコードが直接set_configで入れています。
  • APIとAuthのサーバーは動かしていない。 PostgREST、Supabase Auth、supabase-jsのクライアントは使っていません。そのため、APIを通したときのエラーの形(HTTPの状態コードやエラーの文面)は確かめていません。
  • ロールと権限は、Supabaseに近い形を手で作った。 anon・authenticated・service_roleのロールと、publicスキーマの表への権限は、テストの最初に自分で作っています。本物のプロジェクトの既定の権限と細部まで同じとは限りません。
  • 速さは測っていない。 (select auth.uid())で包む効果や索引の効果は、ドキュメントの説明を紹介しただけで、手元では測っていません。
  • 同時のアクセスは試していない。 PGliteは1つの接続で動かしています。

本物のSupabaseで同じことを確かめるなら、Supabase CLIで手元にSupabaseを立て、supabase-jsで店舗Aと店舗Bの利用者としてログインしてから、この記事のテストと同じ問い合わせを流す形になります。

本番に入れる前に決めておくこと

RLSのSQLを書く前に、「誰が、どの店舗の、どの操作をしてよいか」を表にしておくと、ポリシーの数と中身がそのまま決まります。

  • 役割ごとに、読み取り・登録・更新・削除のどれを許すか(この記事では、削除をオーナーだけに許しました)
  • 1人が複数の店舗に所属するか。本部の人が全店舗を見る必要があるか(あるなら、所属の表に「全店舗」の役割を足すか、本部用の画面をサーバー側に分けるか)
  • シークレットキーを使う処理の一覧と、それぞれがどの店舗のデータを扱うか
  • テストをいつ流すか(マイグレーションのたび、本番へ入れる前)

既製のサービスを使うか、自社に合わせて作るかから考えている段階なら、会社のコラムSaaSか、オーダーメイドかで判断の目安を紹介しています。株式会社bundlyzeでは、予約を受け付けて管理する仕組みを、構想づくりから公開後の運用まで開発しています。複数の店舗で1つのデータベースを使うときの分け方も、要件定義の段階から一緒に決められます。ご相談はシステム開発のページから受け付けています。

再現に使ったコード

表とポリシーのSQLです。前半の「Supabaseの土台をまねる部分」は、本物のSupabaseでは最初から用意されているので、自分で作る必要はありません。

schema.sqlsql
-- ===== 1. Supabase の土台をまねる部分(本物の Supabase では最初から用意されている) =====
create role anon nologin;
create role authenticated nologin;
create role service_role nologin bypassrls;

create schema auth;
create table auth.users (id uuid primary key, email text);

-- Supabase Auth のマイグレーションと同じ定義:PostgREST が入れた JWT の sub を読む
create function auth.uid() returns uuid language sql stable as $$
  select coalesce(
    nullif(current_setting('request.jwt.claim.sub', true), ''),
    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
  )::uuid
$$;
grant usage on schema auth to anon, authenticated, service_role;

-- ===== 2. アプリの表 =====
create table public.tenants (
  id uuid primary key,
  name text not null
);

create table public.memberships (
  tenant_id uuid not null references public.tenants(id),
  user_id uuid not null references auth.users(id),
  role text not null check (role in ('owner', 'staff')),
  primary key (tenant_id, user_id)
);
create index on public.memberships (user_id);

create table public.customers (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id),
  name text not null,
  phone text,
  unique (tenant_id, id)
);
create index on public.customers (tenant_id);

create table public.reservations (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id),
  customer_id uuid not null,
  starts_at timestamptz not null,
  status text not null default 'booked',
  -- 顧客は「同じ店の顧客」しか指せないようにする(RLS は外部キーの確認を止めないため)
  foreign key (tenant_id, customer_id) references public.customers(tenant_id, id)
);
create index on public.reservations (tenant_id);

-- 既存の Supabase のプロジェクトでは、public の新しい表に anon・authenticated・service_role の権限が自動で付く。それをまねる
grant usage on schema public to anon, authenticated, service_role;
grant select, insert, update, delete on all tables in schema public to anon, authenticated, service_role;

-- ===== 3. 所属を調べる関数(API に出さないスキーマに置く) =====
create schema private;
create function private.my_tenant_ids() returns setof uuid
language sql stable security definer set search_path = '' as $$
  select m.tenant_id from public.memberships m where m.user_id = (select auth.uid())
$$;
create function private.is_owner(t uuid) returns boolean
language sql stable security definer set search_path = '' as $$
  select exists (
    select 1 from public.memberships m
    where m.tenant_id = t and m.user_id = (select auth.uid()) and m.role = 'owner'
  )
$$;
grant usage on schema private to authenticated;
revoke execute on all functions in schema private from public;
grant execute on all functions in schema private to authenticated;

-- ===== 4. RLS とポリシー =====
alter table public.tenants      enable row level security;
alter table public.memberships  enable row level security;
alter table public.customers    enable row level security;
alter table public.reservations enable row level security;

create policy "所属している店だけ見える" on public.tenants
  for select to authenticated
  using (id in (select private.my_tenant_ids()));

create policy "自分の所属だけ見える" on public.memberships
  for select to authenticated
  using (user_id = (select auth.uid()));

create policy "所属している店の顧客を見る" on public.customers
  for select to authenticated
  using (tenant_id in (select private.my_tenant_ids()));
create policy "所属している店に顧客を登録する" on public.customers
  for insert to authenticated
  with check (tenant_id in (select private.my_tenant_ids()));
create policy "所属している店の顧客を直す" on public.customers
  for update to authenticated
  using (tenant_id in (select private.my_tenant_ids()))
  with check (tenant_id in (select private.my_tenant_ids()));

create policy "所属している店の予約を見る" on public.reservations
  for select to authenticated
  using (tenant_id in (select private.my_tenant_ids()));
create policy "所属している店に予約を入れる" on public.reservations
  for insert to authenticated
  with check (tenant_id in (select private.my_tenant_ids()));
create policy "所属している店の予約を直す" on public.reservations
  for update to authenticated
  using (tenant_id in (select private.my_tenant_ids()))
  with check (tenant_id in (select private.my_tenant_ids()));
create policy "予約の削除は店のオーナーだけ" on public.reservations
  for delete to authenticated
  using ((select private.is_owner(tenant_id)));

テスト用のデータです。

seed.sqlsql
insert into auth.users values
  ('aaaaaaaa-0000-0000-0000-000000000001', '[email protected]'),
  ('bbbbbbbb-0000-0000-0000-000000000002', '[email protected]');
insert into public.tenants values
  ('11111111-0000-0000-0000-000000000001', '店舗A'),
  ('22222222-0000-0000-0000-000000000002', '店舗B');
insert into public.memberships values
  ('11111111-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000001', 'staff'),
  ('22222222-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000002', 'owner');
insert into public.customers (id, tenant_id, name, phone) values
  ('c0000000-0000-0000-0000-00000000000a', '11111111-0000-0000-0000-000000000001', '顧客A1', '090-0000-0001'),
  ('c0000000-0000-0000-0000-00000000000b', '22222222-0000-0000-0000-000000000002', '顧客B1', '090-0000-0002');
insert into public.reservations (tenant_id, customer_id, starts_at) values
  ('11111111-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-00000000000a', '2026-10-10 10:00+09'),
  ('11111111-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-00000000000a', '2026-10-11 10:00+09'),
  ('22222222-0000-0000-0000-000000000002', 'c0000000-0000-0000-0000-00000000000b', '2026-10-10 13:00+09');

テストのコードです。node test.mjsで流します。

test.mjsjs
// RLS のテスト。PGlite(WebAssembly の PostgreSQL)で、Supabase の API が1リクエストごとに行うことをまねる:
//   トランザクションを始める → ロールを切り替える → JWT の中身を request.jwt.claims に入れる → SQL を流す
import { PGlite } from '@electric-sql/pglite';
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';

const db = new PGlite();
await db.exec(readFileSync('schema.sql', 'utf8'));
await db.exec(readFileSync('seed.sql', 'utf8'));

const STAFF_A = 'aaaaaaaa-0000-0000-0000-000000000001';
const OWNER_B = 'bbbbbbbb-0000-0000-0000-000000000002';
const TENANT_A = '11111111-0000-0000-0000-000000000001';
const TENANT_B = '22222222-0000-0000-0000-000000000002';
const CUSTOMER_A = 'c0000000-0000-0000-0000-00000000000a';
const CUSTOMER_B = 'c0000000-0000-0000-0000-00000000000b';

// role と sub を決めて、1つのトランザクションの中で fn を動かし、最後に必ず取り消す
async function as(role, sub, fn) {
  await db.exec('begin');
  try {
    await db.query(`set local role ${role}`);
    const claims = JSON.stringify(sub ? { sub, role } : { role });
    await db.query(`select set_config('request.jwt.claims', $1, true)`, [claims]);
    return await fn();
  } finally {
    await db.exec('rollback');
  }
}
const rows = async (sql, params) => (await db.query(sql, params)).rows;
async function errorOf(fn) {
  try { await fn(); return null; } catch (e) { return e.message; }
}

let n = 0;
async function check(label, fn) {
  await fn();
  console.log(`ok ${++n} - ${label}`);
}

await check('店舗Aのスタッフには、店舗Aの予約2件だけが見える', () =>
  as('authenticated', STAFF_A, async () => {
    const r = await rows('select tenant_id from reservations');
    assert.equal(r.length, 2);
    assert.ok(r.every((x) => x.tenant_id === TENANT_A));
  }));

await check('店舗Bのオーナーには、店舗Bの予約1件だけが見える', () =>
  as('authenticated', OWNER_B, async () => {
    const r = await rows('select tenant_id from reservations');
    assert.deepEqual(r.map((x) => x.tenant_id), [TENANT_B]);
  }));

await check('ログインしていない(anon)と、予約も顧客も0件', () =>
  as('anon', null, async () => {
    assert.equal((await rows('select * from reservations')).length, 0);
    assert.equal((await rows('select * from customers')).length, 0);
  }));

await check('IDを直接指定しても、他店の顧客は0件(エラーではなく見えないだけ)', () =>
  as('authenticated', STAFF_A, async () => {
    assert.equal((await rows('select * from customers where id = $1', [CUSTOMER_B])).length, 0);
  }));

await check('他店に予約を入れようとすると、WITH CHECK で止まる', () =>
  as('authenticated', STAFF_A, async () => {
    const msg = await errorOf(() => db.query(
      `insert into reservations (tenant_id, customer_id, starts_at) values ($1, $2, now())`,
      [TENANT_B, CUSTOMER_B]));
    console.log('   →', msg);
    assert.match(msg, /row-level security/);
  }));

await check('自店の予約を他店へ付け替える更新も止まる', () =>
  as('authenticated', STAFF_A, async () => {
    const msg = await errorOf(() => db.query(`update reservations set tenant_id = $1`, [TENANT_B]));
    console.log('   →', msg);
    assert.match(msg, /row-level security/);
  }));

await check('他店の予約を消そうとしても、0件の削除で終わる(エラーにならない)', () =>
  as('authenticated', STAFF_A, async () => {
    const r = await db.query(`delete from reservations where tenant_id = $1`, [TENANT_B]);
    assert.equal(r.affectedRows, 0);
  }).then(() =>
  // 削除できる立場(店舗Bのオーナー)でも、他店(店舗A)の予約は0件
  as('authenticated', OWNER_B, async () => {
    const r = await db.query(`delete from reservations where tenant_id = $1`, [TENANT_A]);
    assert.equal(r.affectedRows, 0);
  })));

await check('スタッフは自店の予約も消せない。オーナーは消せる', async () => {
  await as('authenticated', STAFF_A, async () => {
    const r = await db.query(`delete from reservations where tenant_id = $1`, [TENANT_A]);
    assert.equal(r.affectedRows, 0);
  });
  await as('authenticated', OWNER_B, async () => {
    const r = await db.query(`delete from reservations where tenant_id = $1`, [TENANT_B]);
    assert.equal(r.affectedRows, 1);
  });
});

await check('自店の予約に他店の顧客をつなごうとすると、複合の外部キーで止まる', () =>
  as('authenticated', STAFF_A, async () => {
    const msg = await errorOf(() => db.query(
      `insert into reservations (tenant_id, customer_id, starts_at) values ($1, $2, now())`,
      [TENANT_A, CUSTOMER_B]));
    console.log('   →', msg);
    assert.match(msg, /foreign key/);
  }));

await check('service_role はポリシーを素通りして3件すべて見える', () =>
  as('service_role', null, async () => {
    assert.equal((await rows('select * from reservations')).length, 3);
  }));

// ---- ポリシーの抜けを見つける検査(CI で毎回流す想定) ----
async function auditPublicSchema() {
  return rows(`
    select c.relname as table,
           c.relrowsecurity as rls,
           count(p.polname)::int as policies
    from pg_class c
    join pg_namespace ns on ns.oid = c.relnamespace
    left join pg_policy p on p.polrelid = c.oid
    where ns.nspname = 'public' and c.relkind = 'r'
    group by c.relname, c.relrowsecurity
    order by c.relname`);
}

await check('public の全表で RLS が有効', async () => {
  const r = await auditPublicSchema();
  console.table(r);
  assert.deepEqual(r.filter((x) => !x.rls).map((x) => x.table), []);
});

// ---- わざと抜けを作って、検査が見つけるか確かめる ----
await db.exec(`
  create table public.reservation_notes (
    id serial primary key, tenant_id uuid not null, body text not null);
  grant select on public.reservation_notes to anon, authenticated;
  insert into public.reservation_notes (tenant_id, body) values
    ('${TENANT_A}', 'Aのメモ'), ('${TENANT_B}', 'Bのメモ');
`);
const missing = (await auditPublicSchema()).filter((x) => !x.rls).map((x) => x.table);
console.log('   RLS が無効の表 →', missing);
assert.deepEqual(missing, ['reservation_notes']);
const leaked = await as('anon', null, () => rows('select body from reservation_notes'));
console.log('   ログインなしで読めたメモ →', leaked.map((x) => x.body));
console.log(`ok ${++n} - わざと RLS を付け忘れた表を、検査が見つける`);

console.log(`\n${n} 件すべて通りました`);

外部キーをcustomer_idだけにした比較と、管理者の接続での件数を確かめたコードです。

fk-single.mjsjs
// 比較用:顧客への外部キーを customer_id だけにした場合(複合にしない場合)
import { PGlite } from '@electric-sql/pglite';
import { readFileSync } from 'node:fs';

const db = new PGlite();
const schema = readFileSync('schema.sql', 'utf8').replace(
  'foreign key (tenant_id, customer_id) references public.customers(tenant_id, id)',
  'foreign key (customer_id) references public.customers(id)');
await db.exec(schema);
await db.exec(readFileSync('seed.sql', 'utf8'));

await db.exec('begin');
await db.query('set local role authenticated');
await db.query(`select set_config('request.jwt.claims', $1, true)`,
  [JSON.stringify({ sub: 'aaaaaaaa-0000-0000-0000-000000000001', role: 'authenticated' })]);
const r = await db.query(
  `insert into reservations (tenant_id, customer_id, starts_at)
   values ('11111111-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-00000000000b', now())`);
console.log('店舗Aのスタッフが、店舗Bの顧客IDで予約を登録 →', r.affectedRows, '件登録できた');
const seen = await db.query(`select count(*)::int as n from customers where id = 'c0000000-0000-0000-0000-00000000000b'`);
console.log('そのスタッフから店舗Bの顧客は見えるか →', seen.rows[0].n, '件');
await db.exec('rollback');

// 表の持ち主(ここでは PGlite の既定ユーザー postgres)で読むと、ポリシーは効かない
console.log('current_user =', (await db.query('select current_user')).rows[0].current_user);
console.log('表の持ち主で読んだ予約 →', (await db.query('select count(*)::int as n from reservations')).rows[0].n, '件');

動作確認した環境

確認日は2026年10月4日、使ったのは手元のMacです。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2
PGlite 0.5.8(中身は PostgreSQL 18.3。WebAssembly 版)
確かめたこと test.mjsの12項目がすべて通ること、fk-single.mjsの結果、scan-secrets.mjsがダミーの鍵を見つけること

本物のSupabaseのプロジェクト(ホスティングされたものも、Supabase CLIで手元に立てたものも)では実行していません。supabase-js、PostgREST、Supabase Authも動かしていないので、JWTの検証、APIを通したときのエラーの形、本番の速さは確かめていません。

参照した公式ドキュメント(確認日:2026年10月4日)

よくある質問

SupabaseでRLSを有効にしただけで、データは守られますか?

有効にしただけだと、ポリシーが1つもない表は誰からも読めない状態になります。守られてはいますが、画面からも使えません。そのうえで、ログインした人が所属している店の行だけを通すポリシーを、読み取り・登録・更新・削除ごとに足していきます。逆に、RLSを有効にし忘れた表は、anonとauthenticatedに表の権限が付いていれば、公開してよい鍵を持つ人なら誰でも読み書きできてしまうので、付け忘れを機械的に見つけるテストを用意します。

店舗のIDは、ログインした人のJWTに入れて判定するのと、所属の表を引くのと、どちらがよいですか?

1人が複数の店舗に所属したり、退職や異動で所属がすぐ変わったりするなら、所属の表を引く方法が扱いやすいです。JWTに入れた値は、トークンが発行し直されるまで古いまま残ります。JWTに入れる場合も、利用者が自分で書き換えられるuser_metadataではなく、利用者が変えられないapp_metadataを使うよう、Supabaseのドキュメントに書かれています。

service_roleキー(シークレットキー)は、どこで使ってよいですか?

サーバー、Edge Functions、定期実行の処理のように、利用者の手元に届かない場所だけです。このキーはRLSを素通りするため、ブラウザやスマホのアプリに入れると、全店舗のデータを読める鍵を配ることになります。サーバーで使う場合も、RLSに頼れなくなるので、どの店のデータを扱うかをコードの側で必ず絞ります。

他店の予約を消そうとしたとき、エラーにならないのはなぜですか?

PostgreSQLのRLSでは、USINGの条件に合わない行は、エラーではなく「見えない行」として扱われるからです。手元の確認でも、他店の予約を消すDELETEはエラーにならず、0件の削除で終わりました。一方で、他店に予約を入れる・他店へ付け替えるといった書き込みは、WITH CHECKの条件でエラーになります。画面には「0件だった」ことを失敗として伝える作りにします。