対象: 「A社のデータがB社に見えたらどうしよう」が頭をよぎっている、業務アプリをつくり始めた方
所要時間: 40分前後 / 使うもの: Supabaseの無料プラン、ブラウザだけ
続きの記事(このしくみを、実際のWebアプリにしてみた): https://qiita.com/tomozilla/items/7ef7e48699cb38868b67
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/n5d457af8981d
取引先ごとの請求書を管理するアプリをつくる、と想像してください。A社の人がログインしたらA社の請求書だけ、B社の人ならB社の分だけ。当たり前のようで、AIに作ってもらった画面を眺めていると「これ、本当にそうなってる?」と急に不安になります。私はなりました。
この記事では、次の2つの分け方を実際に両方さわって比べます。
- A. 1つのプロジェクトに全社のデータを入れて、RLS(行レベルセキュリティ)で見える行を分ける
- B. お客さまごとにプロジェクトを分ける
メインはAのRLSを手を動かして確かめるところです。Bは「どんな感じか」を、実際にプロジェクトを作って見てみました。
0. 先に伝えておきたいこと(正直ベース)
- 試したのは、この記事のために作った無料プランのデモ環境です。ダミーのユーザー(
alice@example.comとbob@example.com)を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人作りました。
💡 パスワードは使い捨てのランダムな文字列にしました。この記事には載せません。
テーブルを作る
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
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 = 'alice@example.com')
or (t.name = 'Globex Corp.' and u.email = 'bob@example.com');
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 で確かめました)。
結果:
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 でも、できあがったルールを確認できます。
4. 3人になりきって確かめる
ログインしていない人
begin;
set local role anon;
select count(*) as visible_invoices from public.invoices;
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 = 'alice@example.com'),
'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は読めないので。
Acmeの請求書2件だけ。
Bob(Globexの人)になりきる
上のSQLの alice@example.com を bob@example.com に変えるだけです。
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 = 'alice@example.com'),
'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);
ERROR: 42501: new row violates row-level security policy for table "invoices"
今度ははっきり拒否されました。「書く側」も守られています。
6. もうひとつの方法:お客さまごとにプロジェクトを分ける
ここからがBです。Aは「1つの家に、部屋ごとの鍵」。Bは「お客さまごとに、別の家」。実際に、Globex用のプロジェクトを作ってみました。
プロジェクトの一覧がこうなりました。
作ってみて気づいたことです。
- 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)も、拒否されるかを確かめる
- プロジェクト分離は強力だけれど、お客さまの数だけ作業が増える
次回は、AIエージェントにDBを触らせるときの「権限の絞り方」に進みます。
この記事のスクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。メールアドレスはダミー(example.com)です。








