マルチテナントアプリの堅牢化:PostgreSQLの行レベルセキュリティ(RLS)が必要な理由

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

データ漏洩のメカニズム

想像してみてください。あなたはプロジェクト管理のSaaSを立ち上げたばかりです。50の異なる企業(テナント)のユーザーが1つのデータベースを共有しています。整理を容易にするため、すべてのテーブルに tenant_id カラムを追加しました。バックエンドは、すべてのクエリに WHERE tenant_id = ? を付与するようにプログラムされています。

システムは順調に稼働していましたが、ある火曜日の定期アップデートで事態は一変します。開発者がレポートダッシュボード用の簡単なパッチをプッシュしましたが、その際に不可欠な WHERE 句を1つ書き漏らしてしまいました。突然、A社がB社の非公開ロードマップや財務予測を閲覧できる状態になります。これは単なるバグではありません。わずか数分で顧客の信頼を失墜させる、重大なデータ侵害です。

私は長年、MySQL、PostgreSQL、MongoDBなどを使い分けてきました。それぞれに利点がありますが、マルチテナンシーをアプリケーションレイヤーだけで処理するのは、時限爆弾を抱えるようなものだと学びました。ヒューマンエラーは避けられません。データベースがセキュリティルールを「認識」していなければ、タイポ(打ち間違い)一つで大惨事を招く可能性があるのです。

「ただの土管」という誤謬

多くの開発者は、データベースを単なる「データの入れ物(ストレージバケット)」として扱います。スーパーユーザーアカウントで接続し、アプリケーションが唯一のゲートキーパーであることを期待します。これにより、データとセキュリティポリシーの間に危険なギャップが生じます。

セキュリティロジックが Node.js や Python のコード内にしか存在しない場合、3つの壁に突き当たります。

  • コードの肥大化: 何百ものクエリに手動でテナントフィルターを挿入しなければなりません。
  • メンテナンスの負荷: 新しいテーブルを作成するたびに、複数のファイルで新しいフィルターセットを定義する必要があります。
  • 監査の悪夢: コンプライアンス担当者(SOC2など)に対してデータの分離を証明する際、1万行のアプリケーションコードに依存している状態では、その難易度は跳ね上がります。

データを分離する3つの方法

チームがこの問題を解決するためにいくつかの戦略を試すのを見てきました。本番環境での実際のパフォーマンスは以下の通りです。

1. 手動フィルター

これが「標準的」な方法です。SQLクエリに tenant_id を追加するか、ORMのフックを使用します。セットアップは早いですが、非常に脆弱です。複雑な結合(JOIN)を行う生のSQLクエリがたった1つあるだけで、プラットフォーム全体のデータが漏洩するリスクがあります。

2. テナントごとのデータベース分離

このモデルでは、顧客ごとに専用のデータベースまたはスキーマを割り当てます。分離は鉄壁です。しかし、500以上の個別のスキーマのマイグレーションを管理するのは運用の悪夢です。また、システムのメモリや接続制限を急速に消費します。

3. 行レベルセキュリティ(RLS)

RLSは、まさに最適なバランスの解決策です。テーブルに直接セキュリティポリシーを定義できます。ユーザーがクエリを実行すると、PostgreSQLは自動的かつ不可視に、必要なフィルターを付与します。アプリが SELECT * FROM tasks を送信したとしても、PostgreSQLはそのユーザーが表示を許可されているデータのみを返します。

PostgreSQL RLSの実装

RLSはセキュリティをデータベースのネイティブ機能に変えます。これにより、バックエンドコードにゼロデイ脆弱性があったとしても、データベースが最終防衛線として機能します。実際に構築してみましょう。

ステップ1:テーブル構造

まずは基本的な tasks テーブルから始めます。tenant_id カラムに注目してください。これが分離の要となります。

-- tenant_idを持つテーブルを作成
CREATE TABLE tasks (
    id SERIAL PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT
);

-- 2つの異なる企業用のサンプルデータ
INSERT INTO tasks (tenant_id, title) VALUES 
('acme_corp', 'ログインバグの修正'),
('acme_corp', 'ドキュメントの更新'),
('globex', '新しいデザイナーの採用'),
('globex', '第3四半期レポートの作成');

ステップ2:スイッチを入れる

デフォルトでは、RLSは無効になっています。テーブルごとに明示的に有効化する必要があります。これにより、セットアップ中の予期せぬロックアウトを防げます。

ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

プロのアドバイス:テーブルの所有者は引き続きすべてを閲覧できます。日々の操作には、アプリケーションが使用するための専用ロールを作成する必要があります。

CREATE ROLE app_user WITH LOGIN PASSWORD 'secure_pass_99';
GRANT ALL ON tasks TO app_user;

ステップ3:ポリシーの作成

どのテナントがアクティブであるかを PostgreSQL に伝える方法が必要です。セッション変数を使用できます。ポリシーの内容は、「tenant_id がセッション設定と一致する行のみを表示する」というものです。

CREATE POLICY task_isolation_policy ON tasks
    USING (tenant_id = current_setting('app.current_tenant', true));

ステップ4:実践テスト

app_user として接続し、コンテキストを切り替えるアプリケーションの動作をシミュレートしてみましょう。

-- 制限されたユーザーに切り替え
SET ROLE app_user;

-- コンテキストを Acme Corp に設定
SET app.current_tenant = 'acme_corp';

-- このクエリは「魔法のように」結果を自動でフィルタリングします
SELECT * FROM tasks;

クエリは Acme 社のタスクのみを返します。このセッションでは、Globex 社の行はデータベースエンジンから完全に隠されます。これは、アプリケーションのSQLステートメントに WHERE 句を1つも書かずに実現されます。

本番環境でのベストプラクティス

本番環境では、SET ROLE を手動で実行すべきではありません。バックエンド(Node.js、Go、Pythonなど)がトランザクション内でこれを処理すべきです。APIにリクエストが届いたら、JWTからテナントIDを抽出し、以下のようにデータベース呼び出しをラップします:

BEGIN;
SET LOCAL app.current_tenant = 'acme_corp';
-- ここでアプリケーションロジックを実行
SELECT * FROM tasks;
COMMIT;

SET LOCAL を使用することが非常に重要です。これにより、設定のスコープがそのトランザクションのみに限定され、コネクションプール内で同じ接続を共有する他のユーザーに影響が及ぶのを防げます。

パフォーマンスに関する疑問

RLSは動作を重くするでしょうか?私のテストでは、オーバーヘッドは通常2〜3ミリ秒未満です。PostgreSQLはクエリプランニング中に、RLSポリシーを標準の WHERE 句と同じように扱います。軽快な動作を維持するために、tenant_id にインデックスを作成しておくだけで十分です。

CREATE INDEX idx_tasks_tenant_id ON tasks(tenant_id);

最後に

セキュリティをデータベース側に移すのは、モノリシックなアプリケーションロジックに慣れていると直感に反するように感じるかもしれません。しかし、それによって得られる安心感には大きな価値があります。RLSは、チームがどれほど成長し、コードがいかに複雑になっても、A社がB社のデータを決して見ることがないようにする、宣言的で確実な方法を提供します。

次回マルチテナントプロジェクトを開始する際は、PostgreSQLに重労働を任せてみてください。火曜日の午後に安心して眠るための、最も信頼できる方法です。

Share: