PostgreSQL Row Level Security (RLS): マルチテナント・データのセキュリティを「根本」から強化する

Database tutorial - IT technology blog
Database tutorial - IT technology blog

顧客データの「混同」という悪夢

SaaS(Software as a Service)アプリケーションを構築しているなら、会社Aのデータが会社Bに漏洩することを一度は心配したことがあるでしょう。従来の手法は、すべてのSELECTUPDATEDELETE文にWHERE tenant_id = 'company-A'という条件を追加することでした。

しかし、実際の運用はそれほど単純ではありません。開発者が新しいAPIでWHERE句を一つ忘れるだけで、顧客データ全体が露出してしまう可能性があります。PostgreSQLのRow Level Security (RLS) は、データベース層における「最終防衛線」として機能します。これにより、セキュリティがシステムの本質的な一部となり、アプリケーションコードをより簡潔かつ安全に保つことができます。

クイックスタート:5分でRLSを導入する

具体的なシナリオを設定してみましょう:orders(注文)テーブルがあり、各従業員が自分が管理する注文のみを表示できるようにしたいとします。

ステップ1:テーブルの作成とサンプルデータの挿入

-- 個別のユーザーを作成
CREATE USER dev_tung WITH PASSWORD 'pass123';
CREATE USER dev_hoa WITH PASSWORD 'pass123';

-- 注文テーブルを作成
CREATE TABLE orders (
    id serial PRIMARY KEY,
    item_name text,
    owner name DEFAULT current_user
);

-- 基本権限を付与
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO dev_tung, dev_hoa;

-- テストデータを挿入
INSERT INTO orders (item_name, owner) VALUES ('Macbook M3', 'dev_tung');
INSERT INTO orders (item_name, owner) VALUES ('Dell XPS', 'dev_hoa');

ステップ2:RLSの有効化とポリシーの定義

デフォルトでは、Postgresはテーブルへのアクセス権を持つすべてのユーザーが全データを閲覧することを許可しています。行レベルのセキュリティメカニズムを有効にし、ルールを定義する必要があります。

-- RLS機能を有効にする
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- ポリシーを作成:接続中のユーザーとownerが一致する行のみアクセスを許可
CREATE POLICY order_ownership_policy ON orders
USING (owner = current_user);

ステップ3:結果の検証

dev_tungでログインし、SELECT * FROM orders;を実行すると、結果には’Macbook M3’の行のみが表示されます。Postgresは、アプリケーションコードを一行も変更することなく、クエリにフィルタを自動的に「注入」しました。

RLSは内部でどのように動作しているのか?

RLSによってシステムが低速化することを懸念する人が多いですが、実際には、Postgresはポリシー内の式を元のSQL文のWHERE句にマージすることでRLSを処理します。これは自動的なフィルタのようなもので、ユーザーからは透過的に動作します。

ポリシーの2つの主要コンポーネント

  • USING: 既存のデータ(SELECT, DELETE, UPDATE)を制御します。条件が偽の場合、その行は単に無視されるか、削除・更新ができなくなります。
  • WITH CHECK: 新しく入力されるデータ(INSERT, UPDATE)を制御します。例えば、dev_tungが注文を作成する際に、ownerを別の人に設定することを防ぎます。
CREATE POLICY protect_inserts ON orders
FOR INSERT
WITH CHECK (owner = current_user);

実践的なSaaSアプリケーション向けRLSソリューション

本番環境では、アプリケーションは通常、コネクションプール(PgBouncerなど)を使用し、単一のユーザー(例:app_user)でDBに接続します。この場合、current_userは機能しません。最適な解決策はセッション変数 (Session Variables) を使用することです。

セッション変数を使用した実装

セッション設定から値を読み取るようにポリシーを変更します:

CREATE POLICY saas_tenant_policy ON orders
USING (tenant_id = current_setting('app.current_tenant')::int);

アプリケーションコード内で、プールからコネクションを借りた直後に、変数を割り当てるコマンドを実行する必要があります:

-- 現在の顧客IDが101であると仮定
SET app.current_tenant = 101;
SELECT * FROM orders; -- 顧客101のデータのみを取得

注意: 異なるセッション間でのデータ漏洩を防ぐため、各リクエストの後にこの変数がクリアまたはリセットされることを必ず確認してください。

実戦経験:パフォーマンスの罠を回避する

数百万件のレコードを持つ在庫管理システムにRLSを適用した後、私は3つの貴重な教訓を得ました:

1. インデックスは死活問題

RLSは本質的にWHERE句の追加です。tenant_idカラムにインデックスが貼られていない場合、Postgresはすべてのクエリで全表スキャン(Full Table Scan)を実行せざるを得ません。以前のプロジェクトでは、このカラムにインデックスを追加することで、レスポンス時間が800msから10ms未満に短縮されました。

2. Superuser権限に注意

Postgresはデフォルトで、superuserアカウントやテーブルの所有者(owner)に対してRLSを無視します。RLSを有効にしているのに全データが返ってくる場合は、ユーザー権限を確認してください。所有者に対しても強制的に適用するには、以下を使用します:

ALTER TABLE orders FORCE ROW LEVEL SECURITY;

3. ポリシー内での複雑なサブクエリを避ける

USING (tenant_id IN (SELECT id FROM user_permissions ...))のようなポリシーの記述は控えましょう。Postgresはこのサブクエリをデータ行ごとに繰り返し実行する可能性があります。100万行のテーブルでは、パフォーマンスが著しく低下します。代わりに、セッションで使用可能な情報や効率的なJoin手法を使用してください。

まとめ

RLSはすべての問題を解決する「銀の弾丸」ではありませんが、非常に強力な多層防御(Defense in depth)の層となります。これにより、セキュリティロジックをアプリケーションコードから完全に分離できます。高い安全性が求められるシステムでは、早期にRLSを導入することで、信頼性に大きなダメージを与える深刻なデータ漏洩バグを防ぐことができます。

Share: