検証した動機
Supabase を使う際に、RLS の有効オプションがあります。概念としてはなんとなく理解しているつもりでした。行ごとにアクセスを制御する仕組みで、有効にしておいたほうが安全らしい、くらいの認識です。
ただ、実際の挙動を自分で確認したことは一度もありませんでした。有効にすると何が変わるのか、ポリシーを書いていない状態では何が起きるのか。気になったので、Docker で素の PostgreSQL を立てて簡易的に試してみました。
今回確かめること
Row Level Security(行レベルセキュリティ、以下 RLS)は、すごく雑に言えば「アプリが書き忘れた WHERE tenant_id = ... を、データベースが勝手に付け足してくれる仕組み」です。アプリが SELECT * FROM documents と投げても、返ってくるのは自分のテナントの行だけです。
マルチテナントの SaaS で一番怖いのは、この一行の書き忘れで他社のデータを返してしまうことだと思います。クエリが増えるほど、レビューで防ぎきるのは厳しくなります。それをデータベース側で本当に止められるのか、PostgreSQL 17 で 30 分ほど手を動かして確かめていきます。
読み取りの分離から始めて、書き込みの制御、SET LOCAL でのテナント受け渡し、最後に RLS がまったく効かない抜け道まで見ていきます。
1. PostgreSQL を起動する
作業用ディレクトリに docker-compose.yml を 1 つ置くだけで済みます。
services:
postgres:
image: postgres:17
container_name: postgres-rls-lab
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rls_lab
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
起動して psql に入ります。
docker compose up -d
docker exec -it postgres-rls-lab psql -U postgres -d rls_lab
rls_lab=# が出れば準備完了です。以降、\q で抜けて別のユーザーで入り直す場面が何度か出てくるので、上の docker exec はコピーしておくと楽になります。
2. テーブルとアプリ用ロールを用意する
A 社(tenant_id=100)と B 社(tenant_id=200)の資料が同じテーブルに同居している状況を作ります。
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT
);
INSERT INTO documents (tenant_id, title, content) VALUES
(100, 'A社資料1', 'A社だけが見えるデータ'),
(100, 'A社資料2', 'A社の契約情報'),
(200, 'B社資料1', 'B社だけが見えるデータ'),
(200, 'B社資料2', 'B社の契約情報');
この時点で SELECT * FROM documents; は 4 行すべて返す状態にしておきます。
次に、アプリが使うロールを作ります。
RLS の検証はすべてこのロールで行います。
なぜ postgres のまま進めてはいけないのかは、7 節で嫌というほど分かります。
CREATE ROLE app_user LOGIN PASSWORD 'app_password';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO app_user;
GRANT USAGE, SELECT ON SEQUENCE documents_id_seq TO app_user;
3 つの GRANT はそれぞれ別のものを渡しています。
| 文 | 渡すもの | 足りないときのエラー |
|---|---|---|
GRANT USAGE ON SCHEMA public |
スキーマ public の中を名前で指す権利 |
permission denied for schema public |
GRANT ... ON documents |
テーブルへの SELECT / INSERT / UPDATE / DELETE | permission denied for table documents |
GRANT ... ON SEQUENCE documents_id_seq |
id 列の採番に使うシーケンスを進める権利 |
permission denied for sequence documents_id_seq |
1 つ目に出てくるスキーマは、テーブルをしまっておくフォルダのようなものです。public は PostgreSQL が最初から用意しているフォルダで、さっき作った documents もこの中に入っています。フォルダを開く権利が無ければ、中のファイルを読む権利を持っていても辿り着けません。それを渡すのが USAGE です。
ちなみに public の USAGE は最初から全員に付いているので、この 1 行は消しても動きます。あとで public を使わず自分でフォルダを作る構成に変えたとき、そのまま通るように書いてあります。
3 つ目に出てくるシーケンスは、id の連番を採番してくれる仕組みです。id SERIAL と書いた時点で documents_id_seq という名前で自動的に作られています。読むだけなら使わないので、渡し忘れてもこの節では何も起きません。5 節の INSERT で初めて permission denied for sequence documents_id_seq と怒られます。ハマりやすいので、ここで一緒に渡しておきます。
ここまでで渡したのは「documents というテーブルに触ってよいか」までです。GRANT では「どの行に触ってよいか」までは決められません。A 社の行だけ見せる、といった絞り込みを足すのが、次に出てくる RLS です。
3. RLS を有効にしてポリシーを作る
RLS は有効化とポリシー作成の 2 段階に分かれています。まず有効化します。
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
有効にしただけでポリシーが 1 つもない状態だと、app_user からは 0 行しか返りません。許可が何もない以上、何も見えないのが既定の挙動です。Supabase で RLS を有効にした直後、クエリが空の配列しか返さなくなるのも同じ理屈です。
ポリシーを作ります。
CREATE POLICY tenant_isolation_policy
ON documents
USING (tenant_id = current_setting('app.tenant_id')::integer);
やっていることは「行の tenant_id が、いま接続しているセッションの app.tenant_id と一致する行だけ触れる」という宣言です。app.tenant_id は PostgreSQL が用意した変数ではなく、ドット付きの名前なら自由に設定できる仕組みを借りているだけです。
4. 読み取りが分離されるか確かめる
ここからは app_user で接続します。パスワードは app_password です。
docker exec -it postgres-rls-lab psql -U app_user -d rls_lab
まず何も設定せずに読むと、こうなります。
ERROR: unrecognized configuration parameter "app.tenant_id"
ポリシーが参照している変数が無いので、評価できずエラーになります。0 行が返るよりは、むしろ気づきやすくて良い挙動だと思います。
A 社として設定して読みます。
SET app.tenant_id = '100';
SELECT * FROM documents;
id | tenant_id | title
----+-----------+----------
1 | 100 | A社資料1
2 | 100 | A社資料2
B 社に切り替えると、SQL は一文字も変えていないのに結果が入れ替わります。
SET app.tenant_id = '200';
SELECT * FROM documents;
id | tenant_id | title
----+-----------+----------
3 | 200 | B社資料1
4 | 200 | B社資料2
意地悪をしてみます。A 社に戻したうえで、B 社の行を名指しで取りにいきます。
SET app.tenant_id = '100';
SELECT * FROM documents WHERE tenant_id = 200;
返るのは 0 行です。アプリが間違った tenant_id を渡しても、ポリシーの条件と AND で結合されるので他社の行には届きません。これが欲しくて RLS を入れます。
5. 書き込みを止める USING と WITH CHECK
読み取りは塞げました。次は書き込みを止めます。
まず postgres に戻ってポリシーを作り直します。
DROP POLICY tenant_isolation_policy ON documents;
CREATE POLICY tenant_isolation_policy
ON documents
USING (tenant_id = current_setting('app.tenant_id')::integer)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::integer);
2 つの句は守る場所が違います。
| 句 | 問いかけ | 効くコマンド |
|---|---|---|
USING |
どの行を触ってよいか | SELECT / UPDATE / DELETE |
WITH CHECK |
その状態の行を作ってよいか | INSERT / UPDATE |
WITH CHECK を省くと PostgreSQL は USING の式を流用するため、今回の条件なら省いても他テナントの INSERT は弾けます。ただし両者が別の式になった瞬間に挙動が変わるので、書き込みを許すテーブルでは明示しておくほうが安全です。
app_user で接続し直し、A 社として 3 つ試します。
SET app.tenant_id = '100';
-- 自テナントへの INSERT は通る
INSERT INTO documents (tenant_id, title, content)
VALUES (100, 'A社資料3', '追加データ');
-- 他テナントの行を作ろうとすると WITH CHECK に弾かれる
INSERT INTO documents (tenant_id, title, content)
VALUES (200, 'B社データをA社が作る', '本来禁止');
-- ERROR: new row violates row-level security policy for table "documents"
-- 他テナントの行は UPDATE / DELETE の対象にすら入らない
UPDATE documents SET title = '改ざん' WHERE id = 3; -- UPDATE 0
DELETE FROM documents WHERE id = 3; -- DELETE 0
INSERT はエラー、UPDATE と DELETE は 0 件です。この差が WITH CHECK と USING の担当の違いそのものです。他テナントの行は A 社から見ると最初から存在しないので、エラーを出す相手すらいません。
6. SET LOCAL でテナントを渡す
ここまで SET を使ってきましたが、Web アプリで同じことをやると危険です。コネクションプール(接続の使い回し)があるため、A 社のリクエストが残した app.tenant_id = 100 を、次に同じ接続を掴んだ B 社のリクエストが引き継いでしまいます。
SET LOCAL はトランザクションを抜けた時点で値を捨てます。
-- 初期化
SET app.tenant_id = '';
-- トランザクション処理
BEGIN;
SET LOCAL app.tenant_id = '100';
SELECT * FROM documents;
COMMIT;
-- トランザクションを抜けた後でレコード取得
SELECT current_setting('app.tenant_id', true);
コミット後の値は NULL には戻らず、空文字になります。一度 SET LOCAL を通ったパラメータはセッションに名前として登録されるので、以降は未定義ではなく空の既定値として扱われるためです。その状態で SELECT * FROM documents; を投げると、4 節とは違うエラーが出ます。
ERROR: invalid input syntax for type integer: ""
LOCAL を付けない SET で同じ流れを試すと、コミット後も 100 が残ったままで、A 社の 3 行がそのまま見えます。接続を切るまで値は消えません。
| 観点 | SET |
SET LOCAL |
|---|---|---|
| 有効範囲 | セッションが切れるまで | トランザクションの終わりまで |
| コミット後の値 | 設定値が残る | 空文字に戻る |
| 設定漏れ時 | 前の値でクエリが成功する | クエリがエラーで失敗する |
効いてくるのは最後の行です。SET だと設定に失敗した接続が前のテナントのデータを黙って返しますが、SET LOCAL なら同じ失敗がエラーとして表に出ます。リクエストごとにトランザクションを開いて SET LOCAL を流す形にしておけば、設定漏れは事故ではなく例外になります。
7. RLS をすり抜ける2つの経路
ここが実務で一番刺さります。RLS はすべてのロールに等しく効くわけではありません。
postgres で接続して SELECT * FROM documents; を実行すると、app.tenant_id を設定していないのにエラーも出ず、A 社 B 社の全 5 行が返ります。属性を見ると理由が分かります。
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles
WHERE rolname IN ('postgres', 'app_user');
rolname | rolsuper | rolbypassrls
----------+----------+--------------
postgres | t | t
app_user | f | f
rolbypassrls(RLS を無視する属性)が立っているロールは、ポリシーを評価すらしません。ここまでの検証が成立していたのは app_user で接続していたからです。
Supabase でいえば service_role キーがこの立場にあります。RLS をバイパスする前提のキーなので、これをクライアント側に置いてしまうと、ポリシーをどれだけ丁寧に書いても意味がなくなります。
厄介なのはもう 1 つの経路です。スーパーユーザーでも BYPASSRLS でもないロールを作り、documents の所有者にしてから読んでみます。ここは postgres で実行します。
CREATE ROLE owner_user LOGIN PASSWORD 'owner_password';
ALTER TABLE documents OWNER TO owner_user;
GRANT USAGE ON SCHEMA public TO owner_user;
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'owner_user';
rolname | rolsuper | rolbypassrls
------------+----------+--------------
owner_user | f | f
特権は何も付いていません。この状態で owner_user として接続し直し、app.tenant_id を設定しないまま読みます。パスワードは owner_password です。
docker exec -it postgres-rls-lab psql -U owner_user -d rls_lab
SELECT id, tenant_id, title FROM documents ORDER BY id;
id | tenant_id | title
----+-----------+----------
1 | 100 | A社資料1
2 | 100 | A社資料2
3 | 200 | B社資料1
4 | 200 | B社資料2
6 | 100 | A社資料3
エラーも出ず、A 社 B 社の全 5 行が返りました。テーブルの所有者には、自分のテーブルの RLS が既定で適用されません。アプリ用ロールでマイグレーションも流す構成にすると、そのロールが所有者になります。スーパーユーザーを避けたつもりで、RLS がまったく効かないまま本番が動くことになります。
所有者側は FORCE で塞げます。postgres に戻って設定します。
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
もう一度 owner_user で接続して同じ SELECT を投げると、今度は app_user と同じ反応になります。
ERROR: unrecognized configuration parameter "app.tenant_id"
テナントを設定すれば A 社の行だけが返ります。
SET app.tenant_id = '100';
SELECT id, tenant_id, title FROM documents ORDER BY id;
id | tenant_id | title
----+-----------+----------
1 | 100 | A社資料1
2 | 100 | A社資料2
6 | 100 | A社資料3
所有者にもテナント分離が効くようになりました。ただし FORCE を付けてもスーパーユーザーは変わらず全行読めます。RLS はデータベース内の最後の防壁であって、スーパーユーザー権限を持つ相手には無力です。
そのため、ロールは用途で分けておきます。
| ロール | 用途 | RLS |
|---|---|---|
| スーパーユーザー | 初期構築、障害対応 | バイパスする |
| マイグレーション用(テーブル所有者) | DDL の実行 |
FORCE を付ければ効く |
| アプリケーション用 | 通常のリクエスト処理 | 効く |
アプリの接続情報に postgres を書かない、というだけの話ではあります。ただ、開発環境で手軽さを優先してスーパーユーザーのまま進めると、RLS が効いていないことに最後まで気づけません。
8. RLS はどこを守るのか
RLS はアプリケーションの認証・認可の代わりにはなりません。守備範囲はきれいに分かれています。
アプリ側が担うのは「この利用者は誰か」という認証と、「この機能を使ってよいか」という機能レベルの認可、そしてテナント情報をデータベースへ渡すところまでです。PostgreSQL 側の RLS が担うのは「この利用者が触れる行はどれか」だけです。
つまり、アプリ側で
SELECT * FROM documents WHERE tenant_id = 100;
と書くのをやめる話ではありません。そう書いたつもりで書き忘れた SELECT * FROM documents が、他テナントのデータを返さないようにする話です。二重にしておく価値があるのはこの一点に尽きます。
確認できたことの一覧
手を動かしながら追いかける人向けに、各ステップで返ってくるはずの結果を並べておきます。違う結果が出たら、まず接続しているロールを疑ってみてください。
| 検証内容 | 結果 |
|---|---|
| RLS 設定前に SELECT | 全 4 行 |
| RLS 有効かつポリシーなしで SELECT | 0 行 |
app.tenant_id 未設定で SELECT |
エラー |
tenant_id=100 で SELECT |
A 社のみ |
Tenant A から WHERE tenant_id = 200
|
0 行 |
| Tenant A から他テナント INSERT | エラー |
| Tenant A から他テナント UPDATE / DELETE | 0 件 |
SET LOCAL 後にコミット |
値が空文字になり以降はエラー |
SET 後にコミット |
値が残り、そのまま読める |
| スーパーユーザーで SELECT | 全行(バイパス) |
| テーブル所有者で SELECT | 全行(バイパス) |
FORCE 有効後に所有者で SELECT |
テナント分離が効く |
FORCE 有効後にスーパーユーザーで SELECT |
全行(依然バイパス) |
検証が終わったら、ボリュームごと消しておきます。-v を付けないとデータが残り、次に試すとき CREATE TABLE が失敗します。
docker compose down -v
おわりに
なんとなく分かったつもりだった RLS を、一通り動かして確かめられました。Supabase のポリシー設定画面も、USING と WITH CHECK のどちらを書いているのかという目で見られるようになったと思います。
Django と PostgreSQL の 2 コンテナ構成にして、ログインユーザーを切り替えながら同じ ORM コードを叩いてみたいりするとより実践的になるかもですね。.filter(tenant_id=...) を意図的に外しても他テナントが見えないところまで確認できると、RLS を入れる価値がもっとはっきりすると思います。
検証環境は Windows 11 Pro / Docker 29.7.2 / PostgreSQL 17.11(postgres:17 イメージ)です。マイナーバージョンが違っても挙動は変わりませんが、行 ID は実行状況によってずれます。