こんにちは、かつコーチです。
マルチテナント(1つのデータベースで複数の顧客・組織のデータを扱う構成)のSaaSを設計していると、必ず突き当たるのが「テナントAのデータがテナントBに見えてしまわないか」という不安です。
アプリケーションコードで毎回WHERE tenant_id = ?を書く運用は、書き忘れが1箇所でもあれば情報漏えいに直結します。
PostgreSQLには、この問題をデータベース層で解決するRow-Level Security(RLS、行単位でアクセス可能なデータを制御する仕組み)が標準機能として用意されています。
前提知識ありきの上級者向け記事として、RLSの仕組みと実装パターンを解説します。
Row-Level Securityの仕組み
RLSが解決する問題
RLSがない場合、マルチテナントのデータ保護はアプリケーションコードのWHERE句だけに依存します。
-- ❌ アプリ側で毎回この条件を付け忘れないようにする運用は事故のもと
SELECT * FROM orders WHERE tenant_id = current_tenant_id() AND status = 'pending';
開発者が新しいクエリを書くたびにtenant_idの条件を書き忘れるリスクがあり、レビューだけで完全に防ぐのは現実的ではありません。
RLSを有効にすると、テーブルへのすべてのクエリに対して、データベースが自動的に行フィルタを適用します。
アプリ側で条件を書き忘れても、データベース側で強制的にブロックされるため、防御層が1つ増えることになります。
RLSの有効化とポリシー作成
RLSは、テーブルごとに有効化した上で「ポリシー」というルールを定義します。
-- ステップ1: テーブルでRLSを有効化する
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- ステップ2: ポリシーを作成する(自分のテナントの行だけ見える)
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::int);
current_setting('app.current_tenant_id')は、セッション変数からテナントIDを取得する関数です。
アプリケーション側は、接続直後にこのセッション変数をセットします。
-- アプリがコネクション取得時に実行する
SET app.current_tenant_id = '42';
-- これ以降、このセッションでは自動的にtenant_id=42の行だけが見える
SELECT * FROM orders;
USING句とWITH CHECK句の違い
ポリシーには、読み取り時のフィルタ条件(USING)と、書き込み時の検証条件(WITH CHECK)の2種類を指定できます。
-- SELECT/UPDATE/DELETEの対象を絞り込み、かつINSERT/UPDATEの内容も検証する
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::int)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::int);
WITH CHECKを省略するとUSINGの条件が流用されますが、明示的に指定しておくと「他テナントのIDを指定してINSERTしようとする不正な操作」も確実にブロックできます。
# 他テナントのtenant_idを指定してINSERTしようとすると、こういうエラーになる
ERROR: new row violates row-level security policy for table "orders"
実践的なRLSの設計パターン
ロールごとにポリシーを使い分ける
FOR句を使うと、操作の種類(SELECT/INSERT/UPDATE/DELETE)ごとに異なるポリシーを設定できます。
-- 読み取りは自テナントのみ
CREATE POLICY tenant_select_policy ON orders
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id')::int);
-- 削除は管理者ロールだけ許可(さらに厳しい制約)
CREATE POLICY tenant_delete_policy ON orders
FOR DELETE
USING (
tenant_id = current_setting('app.current_tenant_id')::int
AND current_setting('app.current_role') = 'admin'
);
このように操作ごとに条件を分けることで、「一般ユーザーは削除できないが、管理者は削除できる」といった業務要件をデータベース層で表現できます。
テーブル所有者・スーパーユーザーはRLSをバイパスする点に注意
RLSには落とし穴があり、テーブルの所有者(デフォルト)とスーパーユーザーはRLSの制約を受けません。
-- テーブル所有者のロールでは、RLSが適用されずすべての行が見えてしまう
-- ポリシーを機能させるには FORCE ROW LEVEL SECURITY が必要
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
アプリケーションの接続ロールを、うっかりテーブルの所有者ロールと同じにしてしまうと、RLSポリシーが一切効かない状態になります。
これは実際に筆者がハマった落とし穴で、「ポリシーを設定したのに全テナントのデータが見えている」と半日近く原因を調査した結果、接続ロールがテーブル所有者と同一だったというオチでした。
アプリケーションの接続ロールは、テーブルの所有者とは別の、権限を絞ったロールにするのが鉄則です。
BYPASSRLS属性にも注意する
ロールにBYPASSRLS属性が付いていると、そのロールも同様にRLSをすり抜けます。
-- 現在のロール一覧でBYPASSRLSが付いていないか確認する
SELECT rolname, rolbypassrls FROM pg_roles WHERE rolbypassrls = true;
アプリケーション接続用のロールにこの属性が付いていないか、定期的に確認しておくと安心です。
つまずきやすいポイント・トラブル対処
❌Before:セッション変数をセットし忘れて全件0件になる
RLSを有効化した直後、こういうエラーではなく「なぜかデータが0件になる」という現象に遭遇することがあります。
-- app.current_tenant_id をセットし忘れた状態でSELECTすると
SELECT * FROM orders;
-- (0 rows) ← エラーは出ないが、全件フィルタされてしまう
これは、current_setting('app.current_tenant_id')が未設定のまま実行されるとエラーになり、ポリシー評価自体が失敗して結果的に0件扱いになるためです(current_settingの第2引数を省略した場合)。
✅After:デフォルト値を指定してフェイルセーフにする
current_settingの第2引数にtrueを指定すると、未設定時にエラーではなくNULLを返すようになります。
-- 第2引数 missing_ok を true にすると、未設定時はエラーの代わりにNULLになる
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.current_tenant_id', true)::int);
さらに、アプリケーション側の接続処理で「セッション変数のセットを忘れたら例外を投げる」ようなラッパー関数を用意しておくと、セット忘れそのものを早期に検知できます。
❌Before:RLSだけに頼ってインデックスを見落とす
RLSはWHERE句を自動付与する仕組みのため、tenant_idにインデックスがないと、テナントごとのフィルタが毎回テーブルスキャンになってしまいます。
✅After:ポリシーの条件カラムには必ずインデックスを張る
-- RLSポリシーの条件に使うカラムには、通常のクエリと同様にインデックスが必要
CREATE INDEX idx_orders_tenant_id ON orders (tenant_id);
RLSは「セキュリティを担保する仕組み」であって「パフォーマンスを自動で最適化する仕組み」ではない、という点を忘れないようにしてください。
まとめ
この記事のポイント
- Row-Level Securityは、テーブル単位で有効化し、ポリシーで行フィルタ条件を定義するデータベース層のアクセス制御機能
- テーブル所有者やスーパーユーザーはデフォルトでRLSをバイパスするため、
FORCE ROW LEVEL SECURITYとロール分離が必須 - セッション変数の未設定時はエラーではなく0件になりうるため、
current_settingのmissing_ok引数やアプリ側の検証で対策する - RLSはセキュリティ機構であってパフォーマンス最適化ではないため、条件カラムへのインデックスは別途必要
次に読むべき記事
- ロールベースの権限管理(CREATE ROLE)
- PostgreSQLのインデックスの種類(B-tree・GiST・GIN・BRIN)
- PostgreSQLとMongoDB、JSONBはどこまでMongoDBの代わりになるか比較検証
タグ: PostgreSQL, 上級者向け, セキュリティ