SmartHR Tech Blog

SmartHR 開発者ブログ

Active RecordでRow Level Securityを使って安全にテナント間のデータを分離する

従業員データベース機能の開発を担当している渡邉です。最近公開したGemであるactiverecord-tenant-level-securityの紹介をします。

SmartHRにおけるマルチテナントの現在

私たちが開発するSmartHRはお客様ごとに1つの環境を提供する、マルチテナント型SaaSです。サービス全体で1つのデータベースを持ち、複数のテナントのデータが混ざらないように、SQLで問い合わせを行います。

1つの環境ごとに1つのデータベースを持つ方式は安全性の面で優れていますが、スキーマの保守やマイグレーションにかかる時間の増加など、多くの技術的な困難をもたらします。この選択の背景については、2018年に書かれた以下の記事もご覧ください。

tech.smarthr.jp

とはいえ、常にテナントごとのWHERE句を意識しながらコードを書くのは大変ですし、不具合の温床になります。幸い、私たちの利用しているActive Recordにはactiverecord-multi-tenantという心強い味方がいます。

github.com

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です。

www.postgresql.org

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です。

github.com

この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_actionTenantLevelSecurity.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

https://github.com/kufu/activerecord-tenant-level-security/blob/v0.0.1/lib/activerecord-tenant-level-security.rb#L19

ActiveRecord::ConnectionAdapters::AbstractAdapterにはcheckincheckoutのコールバックが定義されています。それらを使うことで、コネクションプールからのチェックアウト時にセッションに値をセットする、という挙動を実現しています。

define_callbacks :checkout, :checkin

https://github.com/rails/rails/blob/v7.0.2.2/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L31

ただし、このコールバックに関しては正式なドキュメントが存在しません。互換性については保証されない可能性がありますが、今後のアップデートでどうなるか追っていく予定です。

api.rubyonrails.org

未対応の課題

まだ開発途上なので、いくつか未対応の課題が残っています。

ひとつはクエリキャッシュです。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