株式会社リゾーム 業務ソリューション事業グループの藤岡です。
弊社ではショッピングセンター向けの製品を自社開発しており、私は BOND WORKS という製品の開発に携わっています。 この製品はマルチテナントアプリケーションであり、テナントごとにデータを分離する必要があります。
今回は、このマルチテナントデータを安全に扱うために、どのような方法を採用したのかをご紹介します。
開発情報
- フレームワーク: Hanami2.3 (Ruby)
- ORM: ROM-rb(rom-sql, Sequel)
- データベース: PostgreSQL
マルチテナントデータを扱う方法
マルチテナントのデータを扱う際に最も気を付けなくてはならないのは、 他テナントへのデータ漏洩 です。
一般的な分離手法には以下の3つがありますが、それぞれにメリットとデメリットがあります。
インスタンス分離
テナントごとにデータベースインスタンスを立てて分離する方法です。
メリット
- 物理的に分離されるため最高レベルの安全性
デメリット
- コネクション管理の複雑化
- マイグレーション適用時間の増加
- インフラ運用コストの増加
データベース分離
テナントごとに異なるデータベースを作成する方法です。
メリット
- インスタンス分離より低コストで安全性を確立出来る
デメリット
- コネクション管理の負荷
- マイグレーション適用時間の増加
テーブル共有
同一テーブルに複数テナントのデータを格納する方法です。
テーブルに tenant_id のような識別子を持たせ、クエリ時に必ず tenant_id を検索条件に含めます。
メリット
- 運用コストが低い
- コネクション管理がシンプル
- マイグレーションは1回で済む
デメリット
- WHERE句に tenant_id を書き忘れると、他テナントのデータが見えてしまうリスクがある
テーブル共有方法で安全に分離するには
インスタンス分離やデータベース分離は、初期のうちは問題ないものの、テナントが増えると負債になる可能性があります。
そのため、弊社ではテーブル共有方式を採用しましたが、この方式には2つの課題があります。
アプリケーションの実装ミス
クエリにWHERE tenant_id = ?を追加し忘れるという些細なミスが、大規模な情報漏洩に直結します。
人間が書く以上、このミスを100%防ぐことは不可能です。直接データベースを操作する時のリスク
アプリケーション側の制御でテナントのデータ制御を行う仕組みを実装しても、運用調査などでエンジニアがGUIツールやSQLコンソールからデータベースを直接操作する際は、アプリケーション側の制御は一切効きません。
これらの課題を解決するのが、PostgreSQLの Row Level Security (RLS) です。
RLSとは
PostgreSQLの Row Level Security(行レベルセキュリティ)は、テーブルの各行へのアクセスをデータベースレベルで制御する機能 です。
RLSを有効にすると、テーブルへのアクセス時に定義済みのポリシーが必ず適用されるため、アプリケーション側でWHERE句を書き忘れても、データベース側で確実にフィルタリングされます。
RLSの適用方法
テーブル作成のマイグレーションで、RLSの設定を追加します。
-- テーブル作成 CREATE TABLE tenants ( id SERIAL PRIMARY KEY, name VARCHAR(128) NOT NULL, ); CREATE TABLE books ( id SERIAL PRIMARY KEY, tenant_id INTEGER NOT NULL REFERENCES tenants(id), name VARCHAR(128) NOT NULL, ); -- パフォーマンスのためインデックスを作成 CREATE INDEX idx_books_tenant_id ON books(tenant_id); -- RLSを有効化 ALTER TABLE books ENABLE ROW LEVEL SECURITY; -- テーブルオーナーにもRLSを適用 ALTER TABLE books FORCE ROW LEVEL SECURITY; -- ポリシーを作成 CREATE POLICY tenant_policy ON books FOR ALL TO PUBLIC USING (tenant_id = current_setting('app.tenant_id', TRUE)::integer);
セッション変数でテナントIDを設定する事で、設定したテナントIDのデータのみにアクセス出来ます。
-- テナントID=123のデータのみアクセス可能にする SET app.tenant_id = 123; -- この後のクエリは自動的にtenant_id=123でフィルタリングされる SELECT * FROM books; -- tenant_id=123のデータのみ返る
RLSの動作確認
実際にRLSが機能しているか確認してみましょう。
-- テナントID=1のデータを挿入 SET app.tenant_id = 1; INSERT INTO books (tenant_id, name) VALUES (1, 'Ruby入門'); -- テナントID=2のデータを挿入 SET app.tenant_id = 2; INSERT INTO books (tenant_id, name) VALUES (2, 'Hanami入門'); -- テナントID=1として検索 SET app.tenant_id = 1; SELECT * FROM books; -- id | tenant_id | name -- ----+-----------+---------- -- 1 | 1 | Ruby入門 -- (1 row) <-- テナント2のデータは見えない -- テナントID未設定で検索 RESET app.tenant_id; SELECT * FROM books; -- id | tenant_id | name -- ----+-----------+------ -- (0 rows) <-- どのデータも見えない
このように、WHERE句を書かなくても自動的にテナント分離が行われます。
Hanami / ROM-rb でRLSの自動適用
RLSの強力さは分かりましたが、開発者が毎回 SET app.tenant_id を手動で実行していては、結局「書き忘れ」のリスクが残ります。
そのため弊社では、HTTPリクエストごとに異なるテナントIDを保持するために RequestStore を利用し、Sequelのコネクション管理部分にフックを差し込みました。
# RLSを適用するSequel拡張 module Sequel module RLSConnectionManager def synchronize(server = nil) super(server) do |conn| # リクエストスコープからテナントIDを取得 tenant_id = RequestStore.store[:tenant_id] conn.exec("SET app.tenant_id = #{tenant_id}") yield conn end end end end
これにより、HTTPリクエスト時にテナントIDをRequestStoreに保存しておき、コネクション取得のたびにSETを実行することで、以降のDBアクセスでは自動的にRLSが適用されます。
リクエスト処理の流れ
- HTTPリクエスト受信
- HanamiのミドルウェアでURLをパースしてテナントIDを特定
- RequestStore[:tenant_id] = 特定したテナントID
- DBクエリ実行時、自動でSET app.tenant_id = XXXを実行
- PostgreSQLがRLSポリシーに基づいてフィルタリング
- 該当テナントのデータのみ返却
まとめ
リクエスト毎にRLSの設定を自動で行える仕組みを構築したことで、テーブル共有方式のメリットを維持しつつ、アプリケーション層でのデータの安全性を堅牢に実現できました。
Railsと違い、HanamiにおけるRLS適用事例は少なく、実装には苦労しましたが、本記事が Hanami + ROM-rb でマルチテナントアプリケーションを開発している方の参考になれば幸いです。