複数の顧客、つまりテナントが 1 つのデータベースを共有するマルチテナント SaaS では、「テナント A のユーザーがテナント B のデータを読み書きできないこと」を構造的に保証する必要があります。アプリケーション層の WHERE tenant_id = ? だけに頼ると、ハンドラを 1 か所書き忘れた瞬間にデータが漏えいします。
このシリーズでは、PostgreSQL の機能を活用してテナント境界を守るための 5 つの基礎技術を、1 つずつ見ていきます。第 1 回の今回は、DB レベルでテナント境界を強制する Row Level Security を扱います。
シリーズ全体の流れは次のとおりです。
| 回 | テーマ | 解決する問題 |
|---|---|---|
| 第 1 回 | Row Level Security でテナント境界を DB レベルで強制する | アプリ層で WHERE tenant_id = ? を書き忘れた経路を DB レベルで塞ぐ |
| 第 2 回 |
SET LOCAL でトランザクションスコープのセッション変数を使う |
コネクションプール返却時の値漏えいを防ぐ |
| 第 3 回 |
SELECT FOR UPDATE で並行 INSERT のシーケンス重複を防ぐ |
テナント別に連番を振るときの競合状態を解消する |
| 第 4 回 |
IS DISTINCT FROM で NULL 安全に比較する |
JSONB の差分判定で NULL を含む比較を安全に扱う |
| 第 5 回 | HMAC チェーンで監査ログの改ざんを検出する | DB に直接書き込まれた監査ログの不正を事後検出する |
この記事で扱うこと
この記事で扱うのは、次の内容です。
- RLS が何を守る仕組みなのか
-
ENABLE ROW LEVEL SECURITYとCREATE POLICYの基本 -
USINGとWITH CHECKの違い -
current_setting('app.tenant_id', TRUE)::uuidの意味 -
BYPASSRLSロールとアプリケーション用ロールの分離 - RLS があってもアプリ層の
WHERE tenant_id = ?を残す理由
次回は、RLS ポリシーが参照する app.tenant_id を、アプリケーションから安全にセットする方法を扱います。
Row Level Security とは何か
Row Level Security は、テーブルの行ごとにアクセス制御をかける PostgreSQL の機能です。
通常の権限管理では、テーブル単位で次のように制御します。
このロールは correction_overrides を SELECT できる
このロールは correction_overrides を INSERT できる
RLS はさらに細かく、行単位で制御します。
このロールは correction_overrides のうち、
tenant_id が現在のテナント ID と一致する行だけ SELECT できる
つまり、アプリケーションが仮に tenant 条件を書いていないクエリを発行したとしても、
SELECT *
FROM correction_overrides;
DB 側で、次のような条件が常に評価されるイメージです。
WHERE tenant_id = current_setting('app.tenant_id', TRUE)::uuid
もちろん実際には SQL が書き換わるわけではありません。ただ、見える行が RLS ポリシーによって制限されます。
RLS を有効化する
例として、テナントごとの補正設定を保存する correction_overrides テーブルを考えます。
CREATE TABLE correction_overrides (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
domain_id UUID NOT NULL,
attr_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
このテーブルで RLS を有効にします。
ALTER TABLE correction_overrides
ENABLE ROW LEVEL SECURITY;
これだけでは、まだ「どの行を見せるか」のルールがありません。
次にポリシーを作ります。
CREATE POLICY correction_overrides_tenant
ON correction_overrides
USING (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
)
WITH CHECK (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
);
RLS の有効化とポリシー作成を行うと、通常のアプリケーション用ロールからは、app.tenant_id と一致する tenant_id の行だけが見えるようになります。
たとえば、リクエスト処理の冒頭で次のように設定しておきます。
SET LOCAL app.tenant_id = '00000000-0000-0000-0000-000000000001';
すると、アプリケーションが次の SQL を実行しても、
SELECT *
FROM correction_overrides;
DB は実質的に、現在の app.tenant_id に一致する行だけを返します。
SET LOCAL の詳細は第 2 回で扱います。
USING と WITH CHECK の違い
RLS ポリシーには、主に USING と WITH CHECK があります。
この 2 つは似ていますが、役割が違います。
USING:
既存行に対して、その行を見てもよいか、更新・削除対象にしてよいかを決める
WITH CHECK:
INSERT または UPDATE の結果、新しい行が条件を満たしているかを検証する
今回のポリシーをもう一度見ます。
CREATE POLICY correction_overrides_tenant
ON correction_overrides
USING (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
)
WITH CHECK (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
);
USING は、SELECT / UPDATE / DELETE で対象になる既存行を絞ります。
たとえば、app.tenant_id がテナント A のとき、テナント B の行は見えません。UPDATE や DELETE の対象にもなりません。
一方、WITH CHECK は INSERT / UPDATE の結果を検証します。
なぜ WITH CHECK も必要なのでしょうか。
理由は、UPDATE で tenant_id を書き換える経路を塞ぐためです。
WITH CHECK がないと何が危ないか
USING だけのポリシーを考えます。
CREATE POLICY correction_overrides_tenant
ON correction_overrides
USING (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
);
この場合、テナント A のセッションでは、テナント A の行だけが UPDATE 対象になります。
一見安全に見えます。
しかし、次のような UPDATE を考えると問題が分かります。
-- セッションでは app.tenant_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' が設定されている
UPDATE correction_overrides
SET
tenant_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
attr_overrides = '{"alt": "悪意のある値"}'
WHERE id = '<テナント A の行 ID>';
更新前の行はテナント A の行です。
そのため、USING 条件は通ります。
しかし、更新後の行はテナント B の tenant_id になっています。
WITH CHECK がないと、更新後の行が現在のテナントに属しているかを検証しません。
つまり、自テナントの行を他テナントの行に変えてしまう経路が残ります。
WITH CHECK を付けておけば、更新後の tenant_id も現在の app.tenant_id と一致している必要があります。
WITH CHECK (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
)
これにより、他テナントの tenant_id へ書き換える UPDATE は拒否されます。
current_setting('app.tenant_id', TRUE)::uuid の意味
ポリシーで使っている次の式を分解します。
current_setting('app.tenant_id', TRUE)::uuid
current_setting は、PostgreSQL の設定値を読む関数です。
app.tenant_id は、アプリケーションが接続ごと、より正確にはトランザクションごとにセットするカスタム設定値です。
第 2 引数の TRUE は重要です。
current_setting('app.tenant_id', TRUE)
この TRUE は、「設定が存在しない場合にエラーではなく NULL を返す」という意味です。
もし app.tenant_id が設定されていなければ、式は NULL になります。
すると、ポリシー式は次のようになります。
tenant_id = NULL::uuid
SQL では、tenant_id = NULL は true になりません。結果は NULL、つまり不明です。
RLS の USING 句では、条件が true になった行だけが見えます。
NULL は true ではないので、行は見えません。
つまり、app.tenant_id をセットし忘れた場合は、全行が見えなくなります。
これはフェイルセーフな挙動です。
app.tenant_id を設定し忘れる
↓
全行が見えない
↓
機能は壊れるが、別テナントのデータは漏れない
データが漏れるより、0 件返って機能が失敗するほうが安全です。
BYPASSRLS ロールとアプリケーション用ロールを分ける
RLS は通常のアプリケーション接続には効いてほしいですが、すべての操作に効いてほしいわけではありません。
たとえば、次のような運用では、テナントをまたいで全データを見る必要があります。
- マイグレーション
- バックアップ
- データ修復
- 管理者による緊急対応
このような用途には、RLS をバイパスできる専用ロールを分けて用意します。
-- マイグレーション・管理用ロール
CREATE ROLE app_admin
WITH LOGIN PASSWORD '...'
BYPASSRLS;
-- 通常アプリケーション用ロール
CREATE ROLE app_runtime
WITH LOGIN PASSWORD '...';
GRANT ALL ON correction_overrides TO app_admin;
-- アプリケーションに必要な権限だけ付与する
GRANT SELECT, INSERT, UPDATE, DELETE ON correction_overrides TO app_runtime;
運用ルールは明確に分けます。
app_runtime:
通常の Web API が使う
RLS が効く
毎リクエスト SET LOCAL app.tenant_id を発行する
app_admin:
マイグレーションやバックアップが使う
RLS をバイパスできる
認証情報を厳重に管理する
実際には、アプリケーションが本当に必要とする操作だけを app_runtime に付与します。不要な DELETE 権限などは外しておくほうが安全です。
なお、RLS にはもう 1 つ重要な前提があります。PostgreSQL では、テーブル所有者は通常 RLS をバイパスできます。テーブル所有者にも RLS を適用したい場合は、FORCE ROW LEVEL SECURITY を使います。
そのため、通常のアプリケーション接続に使う app_runtime をテーブル所有者にしないことが重要です。所有者ロール、通常のアプリケーション用ロール、RLS を明示的に回避する管理用ロールを分けておくと、運用上の責務が明確になります。
app_admin の認証情報が漏れると、全テナントのデータが見えます。
そのため、AWS Secrets Manager や Vault などで厳重に管理し、通常のアプリケーションからは使わないようにします。
RLS があってもアプリ層の WHERE は残す
RLS を入れると、こう思うかもしれません。
DB が tenant_id で絞ってくれるなら、アプリ側の WHERE tenant_id = ? は不要では?
実務では、アプリ側の WHERE tenant_id = ? も残すことが多いです。
理由は 3 つあります。
1. パフォーマンスのため
RLS ポリシーでも行は絞られますが、アプリ側のクエリにも tenant_id を明示しておくと、インデックス設計やクエリの意図が分かりやすくなります。
SELECT *
FROM correction_overrides
WHERE tenant_id = $1
AND domain_id = $2;
RLS は最後の砦として残しつつ、通常のクエリは明示的に絞るほうが運用しやすくなります。
2. エラー応答を分けるため
RLS が効いた結果 0 件になった場合、アプリケーションからは次の 2 つを区別しにくくなります。
本当にデータが存在しない
権限がないため見えない
API としては、次のように分けたいことがあります。
存在しない:
404 not_found
別テナントのデータ:
403 tenant_forbidden
この区別をしたい場合は、アプリケーション層で assertDomainOwnership のような所有確認を明示的に行います。
3. 多層防御のため
RLS は強力ですが、設定ミスやマイグレーションミスが起きる可能性はあります。
アプリ層の WHERE tenant_id = ? が残っていれば、RLS が一時的に外れても、すぐに全データが漏れる状態を避けやすくなります。
安全な設計は、どちらか一方に頼り切らないことです。
アプリ層:
明示的に tenant_id で絞る
DB 層:
RLS で最後の砦を作る
この二重化が、長期運用する SaaS では効きます。
まとめ
PostgreSQL の Row Level Security を使うと、マルチテナント SaaS のテナント境界を DB レベルで強制できます。
今回の基本形は次です。
ALTER TABLE correction_overrides
ENABLE ROW LEVEL SECURITY;
CREATE POLICY correction_overrides_tenant
ON correction_overrides
USING (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
)
WITH CHECK (
tenant_id = current_setting('app.tenant_id', TRUE)::uuid
);
要点を整理します。
ENABLE ROW LEVEL SECURITY:
テーブルで RLS を有効にする
USING:
SELECT / UPDATE / DELETE の対象になる既存行を絞る
WITH CHECK:
INSERT / UPDATE 後の行が条件を満たすか検証する
current_setting('app.tenant_id', TRUE):
現在のテナント ID を読む
未設定なら NULL を返す
BYPASSRLS:
マイグレーションやバックアップ用に分離する
RLS は、アプリケーションの WHERE tenant_id = ? を置き換えるものではありません。
アプリ層の明示的な絞り込みと、DB 層の RLS を組み合わせることで、テナント境界を多層で守れます。
次回は、RLS ポリシーが参照する app.tenant_id を、アプリケーションから安全にセットする方法を扱います。