Supabase RLS入門:顧客ごとに「見えるデータ」を分ける方法と、プロジェクトを分ける方法の比較

対象: 「A社のデータがB社に見えたらどうしよう」が頭をよぎっている、業務アプリをつくり始めた方
所要時間: 40分前後 / 使うもの: Supabaseの無料プラン、ブラウザだけ
続きの記事(このしくみを、実際のWebアプリにしてみた): 取引先ごとに見えるデータが変わる「請求書ポータル」をつくってみた:ログインしたら自社の分だけが出てくる
ビジネス編(やさしい解説): AIでアプリをつくる人のための「認証」と「認可」:「ログインできる」と「見ていい」は別の話

取引先ごとの請求書を管理するアプリをつくる、と想像してください。A社の人がログインしたらA社の請求書だけ、B社の人ならB社の分だけ。当たり前のようで、AIに作ってもらった画面を眺めていると「これ、本当にそうなってる?」と急に不安になります。私はなりました。

この記事では、次の2つの分け方を実際に両方さわって比べます。

  • A. 1つのプロジェクトに全社のデータを入れて、RLS(行レベルセキュリティ)で見える行を分ける
  • B. お客さまごとにプロジェクトを分ける

メインはAのRLSを手を動かして確かめるところです。Bは「どんな感じか」を、実際にプロジェクトを作って見てみました。

0. 先に伝えておきたいこと(正直ベース)

  • 試したのは、この記事のために作った無料プランのデモ環境です。ダミーのユーザー([email protected] と [email protected])を2人作りました。
  • 「ログインした人になりきる」確認は、SQL Editorで、ログイン済みユーザーの状態を再現して行いました。実際のアプリ(ブラウザ)からログインして確かめたわけではありません。仕組みは同じはずですが、「アプリ経由では未確認」と書いておきます。
  • Bのプロジェクト分離は、プロジェクトを1つ追加で作って見え方を確認しただけです。データの移行や、運用の手間は測っていません。比較表の後半は、私の想像・整理を含みます(そう書いた箇所には印をつけます)。

1. 題材:取引先ごとの請求書

3つのテーブルを使います。

テーブル 役割
tenants 取引先(A社=Acme、B社=Globex)
memberships 「どのユーザーが、どの取引先の人か」の対応表
invoices 請求書(どの取引先のものか、tenant_id を持つ)

ポイントは memberships です。「ログインしている人」と「取引先」をつなぐ表で、あとで「この人はどの会社の人?」を調べるのに使います。

ダミーユーザーを作る

Authentication → Users → Add user → Create new user で、メールアドレスとパスワードを入れます(Auto Confirm User にチェックを入れると、確認メールなしで使えます)。2人作りました。

作成した2人のダミーユーザー

💡 パスワードは使い捨てのランダムな文字列にしました。この記事には載せません。

テーブルを作る

SQL Editorで実行します。

create table public.tenants (
  id uuid primary key default gen_random_uuid(),
  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),
  primary key (tenant_id, user_id)
);
create table public.invoices (
  id bigint primary key generated always as identity,
  tenant_id uuid not null references public.tenants(id),
  title text not null,
  amount_jpy int not null
);

実行しようとすると、こんな画面が出ました。

Potential issue detected: RLSなしでテーブルを作ろうとしています

Potential issue detected
This query creates tables without enabling Row Level Security. Clients using anon or authenticated keys may be able to access these tables.

「RLSを有効にしないままテーブルを作ろうとしていますよ」という親切な警告です。選択肢は Run without RLS と Run and enable RLS の2つ。

普通は Run and enable RLS を選ぶのがよさそうですが、今回は「RLSがないと何が起きるか」を見たかったので、あえて Run without RLS を選びました。

2. RLSなしだと、どうなる?

ダミーデータを入れます。Alice は Acme、Bob は Globex の所属にします。

insert into public.tenants (name) values ('Acme Inc.'), ('Globex Corp.');

insert into public.memberships (tenant_id, user_id)
select t.id, u.id from public.tenants t
join auth.users u on (t.name = 'Acme Inc.' and u.email = '[email protected]')
                  or (t.name = 'Globex Corp.' and u.email = '[email protected]');

insert into public.invoices (tenant_id, title, amount_jpy)
select t.id, v.title, v.amount from public.tenants t
join (values
  ('Acme Inc.', 'Website renewal', 120000),
  ('Acme Inc.', 'Support plan', 30000),
  ('Globex Corp.', 'Data migration', 450000),
  ('Globex Corp.', 'Training workshop', 80000)
) as v(tname, title, amount) on v.tname = t.name;

次に、ログインしていない人(anon) になりきって、請求書を見てみます。

begin;
set local role anon;
select t.name as tenant, i.title, i.amount_jpy
from public.invoices i join public.tenants t on t.id = i.tenant_id
order by i.id;

begin; と set local role を使うと、この1回の実行の間だけ別の役割になれます。実行が終わるともとのpostgresに戻ります(あとで select current_user で確かめました)。

結果:

RLSなしだと、ログインしていない人にも全社の請求書が見える

4件、ぜんぶ見えました。 ログインしていない人にも、A社とB社の請求書が。

これは、プロジェクトを作るときの設定が初期のままだったからです(私の場合、「新しいテーブルを自動でAPIに公開する」にチェックが入っていました)。RLSをオンにしていないテーブルは、公開されたテーブルとして、誰からでも読めてしまう。ここが一番「ヒヤッ」としたところでした。

3. RLSをオンにして、ルール(ポリシー)を書く

alter table public.tenants enable row level security;
alter table public.memberships enable row level security;
alter table public.invoices enable row level security;

オンにしただけでは「誰にも何も見せない」状態になります(ルールがないので)。次に、見せていい条件を書きます。

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

-- 自分が所属する取引先だけ見える
create policy "members see their own tenants" on public.tenants
  for select to authenticated
  using (id in (select tenant_id from public.memberships
                where user_id = (select auth.uid())));

-- 自分が所属する取引先の請求書だけ見える
create policy "members see their tenant's invoices" on public.invoices
  for select to authenticated
  using (tenant_id in (select tenant_id from public.memberships
                       where user_id = (select auth.uid())));

-- 自分が所属する取引先の請求書だけ追加できる
create policy "members add invoices to their tenant" on public.invoices
  for insert to authenticated
  with check (tenant_id in (select tenant_id from public.memberships
                            where user_id = (select auth.uid())));

読み方のコツです。

  • to authenticated:ログイン済みの人に対するルール(ログインしていない人にはルール自体がないので、何も見えません)
  • auth.uid():今ログインしている人のID
  • using (...):見ていい行の条件(select)
  • with check (...):書いていい行の条件(insert)
  • (select auth.uid()) と括弧で包んでいるのは、速度のための書き方と言われているものです(私は計測していません)

ダッシュボードの Authentication → Policies でも、できあがったルールを確認できます。

invoicesに付いたポリシー

4. 3人になりきって確かめる

ログインしていない人

begin;
set local role anon;
select count(*) as visible_invoices from public.invoices;
RLSをオンにした後、ログインしていない人は0件

0。さっきは4件見えていたものが、見えなくなりました。

Alice(Acmeの人)になりきる

ログイン済みユーザーを再現するには、「誰としてログインしているか」を表す情報(JWTのclaims)をセットして、役割を authenticated にします。

begin;
select set_config('request.jwt.claims',
  json_build_object('sub', (select id from auth.users where email = '[email protected]'),
                    'role', 'authenticated')::text, true);
set local role authenticated;
select t.name as tenant, i.title, i.amount_jpy
from public.invoices i join public.tenants t on t.id = i.tenant_id
order by i.id;

💡 auth.users を読むのは、authenticated に切り替える前にしています。切り替えたあとは、auth.users は読めないので。

Aliceには、Acmeの2件だけが見える

Acmeの請求書2件だけ。

Bob(Globexの人)になりきる

上のSQLの [email protected] を [email protected] に変えるだけです。

Bobには、Globexの2件だけが見える

Globexの2件だけ。同じ質問(SQL)なのに、人によって答えが違う。これが、RLSがやってくれることです。

5. 書き込みはどうなる?(ここで私は勘違いしました)

AliceがGlobexの請求書を勝手に追加できてしまわないか、確かめます。最初に、こう書きました。

-- (最初に書いたもの)
insert into public.invoices (tenant_id, title, amount_jpy)
select id, 'Sneaky invoice', 1 from public.tenants where name = 'Globex Corp.';

結果は…エラーが出ませんでした。「あれ、通っちゃった?」と一瞬あわてました。

でも請求書の件数は4件のままでした。よく考えると、Aliceには Globexの tenants の行がそもそも見えません(さっきのポリシーのおかげです)。だから select ... where name = 'Globex Corp.' が0行を返し、0件のinsertが成功していただけでした。

つまずきメモ:「エラーが出ない」=「安全が確かめられた」ではありませんでした。RLSは見えない行を、エラーにせず“なかったこと”にすることがあります。

そこで、GlobexのIDを先に取っておいて、そのIDを直接指定して試しました。

begin;
select set_config('app.globex_id',
  (select id::text from public.tenants where name = 'Globex Corp.'), true);
select set_config('request.jwt.claims',
  json_build_object('sub', (select id from auth.users where email = '[email protected]'),
                    'role', 'authenticated')::text, true);
set local role authenticated;
insert into public.invoices (tenant_id, title, amount_jpy)
values (current_setting('app.globex_id')::uuid, 'Sneaky invoice', 1);
AliceがGlobexの請求書を追加しようとすると、拒否される
ERROR: 42501: new row violates row-level security policy for table "invoices"

今度ははっきり拒否されました。「書く側」も守られています。

6. もうひとつの方法:お客さまごとにプロジェクトを分ける

ここからがBです。Aは「1つの家に、部屋ごとの鍵」。Bは「お客さまごとに、別の家」。実際に、Globex用のプロジェクトを作ってみました。

プロジェクトの一覧がこうなりました。

プロジェクトが2つ並んだ

作ってみて気づいたことです。

  • 1つ作るのに、名前・データベースのパスワード・リージョンを選ぶ。1つ目は ap-southeast-2、2つ目は ap-south-1 と、気づかないうちにリージョンがバラバラになりました(作成画面の「おすすめ」を選んだ結果で、私が意図したものではありません)。
  • テーブルやRLSの設定、Edge Functionなどは、プロジェクトごとに別々。お客さまが増えるたびに、同じ作業を繰り返すことになります。
  • 無料プランの画面には、プロジェクト数の上限があるようです(私は2つまで作成。3つ目は試していません)。

2つの方法の比較

観点 A. 1プロジェクト + RLS B. お客さまごとにプロジェクト
分離の強さ ポリシーの書き方しだい(書き間違えると漏れる) データベース自体が別。とても強い
お客さまが増えたとき 行を足すだけ プロジェクトを足す(作業・管理が増える)※
テーブルの変更(マイグレーション) 1回 プロジェクトの数だけ ※
費用 まとめられる ※ プロジェクトごとにかかる ※
向いていそうな場面 小〜中規模のお客さま多数、機能は共通 数が少ない大口顧客、契約上「完全分離」が必須
私が実際に確かめたこと ✅ ポリシーで見える行が変わる ✅ プロジェクトを作ると完全に別になる(データ移行・運用負荷は未測定)

※は、私の想像・整理で、実際には測っていません。料金は変わるので、最新の情報を確認してください。

気をつけたいこと(ドキュメントで見かけたもの・未検証)

次のことは、私は今回試していません。

  • サーバー用の強い鍵(service_role)はRLSを無視する、と聞きます。ブラウザなど外には出さない。
  • ユーザー自身が書き換えられる情報(user_metadata)を、権限の判断に使ってはいけない、と言われています。
  • ビューは、RLSをすり抜ける作りになることがある、と聞きます。使うときは設定の確認が必要そうです。

まとめ

  • RLSがないと、ログインしていない人にも全部見える状態になりえた(私のデモでは実際に4件ぜんぶ見えた)
  • RLSをオンにして、ポリシーを書くと、同じSQLでも人によって見える行が変わる
  • 「エラーが出ない」は「安全」とは限らない。書く側(insert)も、拒否されるかを確かめる
  • プロジェクト分離は強力だけれど、お客さまの数だけ作業が増える