従業員データベース機能の開発を担当している渡邉です。最近公開したGemであるactiverecord-tenant-level-securityの紹介をします。
SmartHRにおけるマルチテナントの現在
私たちが開発するSmartHRはお客様ごとに1つの環境を提供する、マルチテナント型SaaSです。サービス全体で1つのデータベースを持ち、複数のテナントのデータが混ざらないように、SQLで問い合わせを行います。
1つの環境ごとに1つのデータベースを持つ方式は安全性の面で優れていますが、スキーマの保守やマイグレーションにかかる時間の増加など、多くの技術的な困難をもたらします。この選択の背景については、2018年に書かれた以下の記事もご覧ください。
とはいえ、常にテナントごとのWHERE句を意識しながらコードを書くのは大変ですし、不具合の温床になります。幸い、私たちの利用しているActive Recordにはactiverecord-multi-tenantという心強い味方がいます。
activerecord-multi-tenantはActive Recordで生成されるクエリに、暗黙的にWHERE句を付与します。
class Employee multi_tenant :tenant # activerecord-multi-tenantを有効化 end
Employee.all.to_sql # => SELECT * FROM employees MultiTenant.current_tenant = tenant.id Employee.all.to_sql # => SELECT * FROM employees WHERE tenant_id = ...
これによって、実装者は特別な注意をしなくてもテナント間のデータを安全に分離できます。
activerecord-multi-tenantで防げない問題
activerecord-multi-tenantによるデータの分離は多くの場合にうまく機能しますが、万能ではありません。例えば、Active Recordによって構築されないクエリは当然ながら書き換えられません。
ActiveRecord::Base.connection.execute("SELECT * FROM employees") # => SELECT * FROM employees
他にも、Arelを使って組み立てたサブクエリは書き換えられません(departments
テーブルにWHERE tenant_id = ...
が付与されない)。
employee = Employee.arel_table department = Department.arel_table query = employee[:id].in(department.project("employee_id")) Employee.where(subquery).to_sql # => SELECT * FROM employees # WHERE id IN (SELECT employee_id FROM departments) # AND tenant_id = ...
後者は最終的な結果が絞り込まれているため、問題になることはほとんどありません。しかし、このようにActive Recordによるクエリの書き換えには限界があることは注目すべき点です。
さらに設定の不備や不具合の影響を受けることもあります。例えば、v1.1.1ではmulti_tenant
の宣言後にself.table_name=
でテーブル名を変更すると、クエリの書き換えが効きません*1。
class Employee multi_tenant :tenant self.table_name = :workers end Employee.all.to_sql # => SELECT * FROM employees
これはクエリ書き換え用のクラス名とテーブル名の対応関係を、multi_tenant
の宣言時に内部的に保持するために発生しています。
多層防御としてのRow Level Security
テナント間のデータの分離のような、重大なセキュリティの問題に取り組むにあたって、多層防御の考え方は重要です。今回の例で言うと、activerecord-multi-tenantが何らかの理由で機能しなかった場合、即座に情報流出とならないような「もうひとつ」の層による防御が求められます。
ここで脚光を浴びるのがPostgreSQLのRow Level Securityです。
Row Level Security(以下、RLS)はPostgreSQLが提供する権限の一部として、接続ユーザーに対して操作できる行を制限できる機能です。適切にポリシーが設定されたテーブルでは、同じクエリでも、接続ユーザーに対して異なる結果を返すことができます。
Employee.pluck(:name) # => ["Jane", "Tom"] # => SELECT * FROM employees # 別のユーザーとして接続し直す ActiveRecord::Base.establish_connection(another_user_config) Employee.pluck(:name) # => ["Jane"] # => SELECT * FROM employees
ポリシーの設定はCREATE POLICY
でテーブルごとに行います。以下の例はtenant_id = 1
のemployeesテーブルの行のみ、操作できるポリシーです。
CREATE POLICY tenant_policy ON employees AS PERMISSIVE FOR ALL TO PUBLIC USING (tenant_id = 1) WITH CHECK (tenant_id = 1)
activerecord-multi-tenantがクエリを書き換えて、アプリケーションレベルでの防御を提供する一方で、RLSはデータベースレベルでの防御を提供します。これによって、一方に問題があっても全体としてテナント間のデータが分離されていることを保証できます。
activerecord-tenant-level-securityのご紹介
このような便利なRLSをActive Recordから簡単に利用するための拡張が、今回ご紹介するactiverecord-tenant-level-securityです。
このGemでは、テーブルごとのポリシーの作成を慣れ親しんだマイグレーションの一部として宣言できます。
class CreateEmployee < ActiveRecord::Migration[6.0] def change create_table :employees do |t| t.integer :tenant_id t.string :name end create_policy :employees # <= コレ end end
create_policy
によって、以下のようなポリシーが作成されます。
CREATE POLICY tenant_policy ON employees AS PERMISSIVE FOR ALL TO PUBLIC USING (tenant_id::text = current_setting('tenant_level_security.tenant_id')) WITH CHECK (tenant_id::text = current_setting('tenant_level_security.tenant_id'))
tenant_id
を固定値ではなく、セッションに設定された値を参照することで、テナントごとに接続ユーザーやポリシーを作成しなくても済むようにしています。この値はTenantLevelSecurity.switch!
などで切り替えることができます。
TenantLevelSecurity.switch!(tenant1.id) # => SET tenant_level_security.tenant_id = '1' TenantLevelSecurity.with(tenant2.id) do # => SET tenant_level_security.tenant_id = '2' Employee.pluck(:name) end # => SET tenant_level_security.tenant_id TO DEFAULT
あとはactiverecord-multi-tenantと同様に、コントローラーごとにbefore_action
でTenantLevelSecurity.switch!
しても良いですし、Rack MiddlewareでTenantLevelSecurity.with
しても良いでしょう。
注意点としては、スレッドを使用する場合や、リクエストの中で再接続する場合に、セッションの設定値がリセットされてしまう問題があります。acitverecord-tenant-level-securityでは、新しいコネクションをチェックアウトする際に呼び出すコールバックを登録することで、この問題を解決しています。
# デフォルトのテナントを登録 TenantLevelSecuriy.current_tenant_id { tenant2.id } Thread.new { # 新しいスレッドでコネクションをチェックアウトする Employee.connection # => SET tenant_level_security.tenant_id = '2' }.join
activerecord-multi-tenantと併用しているならば、このコールバックにはMultiTenant.current_tenant_id
が使えます。
TenantLevelSecuriy.current_tenant_id { MultiTenant.current_tenant_id }
内部的な仕組み
activerecordの名を冠するGemですが、Active Recordの内部的な挙動への依存はほとんどありません。唯一大きく依存している部分は、コネクションプールからのチェックアウト時のコールバックの登録部分です。
ActiveRecord::ConnectionAdapters::AbstractAdapter.set_callback :checkout, :after do |conn| TenantLevelSecurity.switch_with_connection!(conn, TenantLevelSecurity.current_tenant_id) end
ActiveRecord::ConnectionAdapters::AbstractAdapter
にはcheckin
とcheckout
のコールバックが定義されています。それらを使うことで、コネクションプールからのチェックアウト時にセッションに値をセットする、という挙動を実現しています。
define_callbacks :checkout, :checkin
ただし、このコールバックに関しては正式なドキュメントが存在しません。互換性については保証されない可能性がありますが、今後のアップデートでどうなるか追っていく予定です。
未対応の課題
まだ開発途上なので、いくつか未対応の課題が残っています。
ひとつはクエリキャッシュです。Active RecordはクエリキャッシュをSQLに対して保持するため、クエリキャッシュが有効になっている場合、誤った結果を返す可能性があります。
Employee.pluck(:name) # => ["Jane", "Tom"] ActiveRecord::Base.establish_connection(another_user_config) # クエリキャッシュを有効化 Employee.connection.enable_query_cache! TenantLevelSecurity.with(tenant1.id) do Employee.pluck(:name) # => ["Jane"] # => SELECT * FROM employees end TenantLevelSecurity.with(tenant2.id) do # 本当はTomを返すべきなのに、変わらない... Employee.pluck(:name) # => ["Jane"] end
Railsではリクエストごとにクエリキャッシュを保持するため、ひとつのリクエストで複数のテナントを跨ぐ処理を行わない限りは、問題になりません。しかし、直感に反する振る舞いなので、修正したいところです。切り替え時にクエリキャッシュを破棄すれば解決できそうな気がします。
もうひとつはactiverecord-multi-tenantとRLSの「どちらか一方が壊れたときにそれに気づく方法」が無いことです。一方の防御機構が壊れていたときにすぐに気付くことができなければ、結局ひとつの防御機構頼りになっていた、なんてことが起きてしまうかもしれません。
RLSによってフィルタされた行が発生したらログを残す、みたいなことができればいいんですけどね...
終わりに
activerecord-tenant-level-securityの運用実績は既に一年程度ありますが、残念ながらまだすべてのプロダクトに導入できている状態ではありません。パフォーマンスへの影響など、考慮すべき点は多くありますが、より安全なデータの分離のために、導入を推進していきたいと思います。
*1:この不具合は修正が提案されています。https://github.com/citusdata/activerecord-multi-tenant/pull/128