HanamiとRLSを活用したマルチテナントデータ管理

株式会社リゾーム 業務ソリューション事業グループの藤岡です。

弊社ではショッピングセンター向けの製品を自社開発しており、私は BOND WORKS という製品の開発に携わっています。 この製品はマルチテナントアプリケーションであり、テナントごとにデータを分離する必要があります。

今回は、このマルチテナントデータを安全に扱うために、どのような方法を採用したのかをご紹介します。

開発情報

マルチテナントデータを扱う方法

マルチテナントのデータを扱う際に最も気を付けなくてはならないのは、 他テナントへのデータ漏洩 です。
一般的な分離手法には以下の3つがありますが、それぞれにメリットとデメリットがあります。

インスタンス分離

テナントごとにデータベースインスタンスを立てて分離する方法です。

メリット
  • 物理的に分離されるため最高レベルの安全性
デメリット
  • コネクション管理の複雑化
  • マイグレーション適用時間の増加
  • インフラ運用コストの増加

データベース分離

テナントごとに異なるデータベースを作成する方法です。

メリット
デメリット

テーブル共有

同一テーブルに複数テナントのデータを格納する方法です。
テーブルに tenant_id のような識別子を持たせ、クエリ時に必ず tenant_id を検索条件に含めます。

メリット
デメリット
  • WHERE句に tenant_id を書き忘れると、他テナントのデータが見えてしまうリスクがある

テーブル共有方法で安全に分離するには

インスタンス分離やデータベース分離は、初期のうちは問題ないものの、テナントが増えると負債になる可能性があります。

そのため、弊社ではテーブル共有方式を採用しましたが、この方式には2つの課題があります。

  1. アプリケーションの実装ミス
    クエリに WHERE tenant_id = ? を追加し忘れるという些細なミスが、大規模な情報漏洩に直結します。
    人間が書く以上、このミスを100%防ぐことは不可能です。

  2. 直接データベースを操作する時のリスク
    アプリケーション側の制御でテナントのデータ制御を行う仕組みを実装しても、運用調査などでエンジニアがGUIツールやSQLコンソールからデータベースを直接操作する際は、アプリケーション側の制御は一切効きません。

これらの課題を解決するのが、PostgreSQLの Row Level Security (RLS) です。

RLSとは

PostgreSQLの Row Level Security(行レベルセキュリティ)は、テーブルの各行へのアクセスをデータベースレベルで制御する機能 です。
RLSを有効にすると、テーブルへのアクセス時に定義済みのポリシーが必ず適用されるため、アプリケーション側でWHERE句を書き忘れても、データベース側で確実にフィルタリングされます。

www.postgresql.jp

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のコネクション管理部分にフックを差し込みました。

github.com

# 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が適用されます。

リクエスト処理の流れ

  1. HTTPリクエスト受信
  2. HanamiのミドルウェアでURLをパースしてテナントIDを特定
  3. RequestStore[:tenant_id] = 特定したテナントID
  4. DBクエリ実行時、自動でSET app.tenant_id = XXXを実行
  5. PostgreSQLがRLSポリシーに基づいてフィルタリング
  6. 該当テナントのデータのみ返却

まとめ

リクエスト毎にRLSの設定を自動で行える仕組みを構築したことで、テーブル共有方式のメリットを維持しつつ、アプリケーション層でのデータの安全性を堅牢に実現できました。

Railsと違い、HanamiにおけるRLS適用事例は少なく、実装には苦労しましたが、本記事が Hanami + ROM-rb でマルチテナントアプリケーションを開発している方の参考になれば幸いです。