こんにちは。ENECHANGEの杉浦です。
PostgreSQLのRow Level Securityを利用したマルチテナントアーキテクチャのシステムをRailsで構築する際のポイントを記載します。
構築する際にはまりどころもあるため本記事で導入のハードルが下がれば幸いです。
一番最後にサンプルコードのリンクも記載しています。
はじめに
PostgreSQLのRow Level Security(RLS)は、データベースのテーブルに誰がどの行にアクセスできるかを細かく制御するための機能です。
DB接続毎に同じテーブルを参照しても見える行が異なるという状況を作り出せます。
アプリケーション側で逐一データ絞り込み処理を書かなくても、データベース側の機能でセキュアなデータアクセスを実現できます。
この特性を活かして複数の顧客企業が同じアプリケーションサーバー、データベースを共有するマルチテナントアーキテクチャでメリットを発揮します。
この記事で書くこと
書くこと
- 今回実現する事
- DB構成
- Railsへの導入
- Rspecへの導入
- 環境毎のMigration実行
書かないこと
- RLSの技術的説明
今回実現する事
テーブル構成
今回の事例では顧客企業に対して属するユーザーのテーブル構成です。
ユーザー情報へのアクセスを特定の顧客企業に制限するものとします。
- 顧客企業: tenants table
create_table :tenants, id: false do |t| t.string :id, limit: 36, primary_key: true t.string :name, null: false, default: '' t.timestamps end
- 顧客企業に属するユーザー: users table
RLS policy適用対象のテーブルとなります。 今回の設計的にpolicy適用するtableにはtenant_idが存在する必要があります。
create_table :users, id: false do |t| t.string :id, limit: 36, primary_key: true t.string :name, null: false, default: '' t.references :tenant, null: false, type: :string, foreign_key: { on_delete: :cascade } t.timestamps end
Rails consoleでの実行
以下の様にwith_tenantで指定したtenantで絞り込み、User検索に適用される様に実装します。 rubyらしくblock内にpolicy適用が限定することが出来ます。
tenant1 = Tenant.create(name: '企業1') tenant2 = Tenant.create(name: '企業2') ApplicationRecord.with_tenant(tenant1.id) do User.create(name: 'ユーザ1', tenant: tenant1) # 企業1に属するユーザのみ参照可能 puts "User.count: #{User.count}" # => User.count: 1 end ApplicationRecord.with_tenant(tenant2.id) do puts "User.count: #{User.count}" # => User.count: 0 user = User.create(name: 'ユーザ2', tenant: tenant2) # 企業2に属するユーザのみ参照可能 puts "User.count: #{User.count}" # => User.count: 1 puts "User name: #{user.name}" # => ユーザ2 end # ブロック外ではテナント指定がないためユーザが参照できない puts "User.count: #{User.count}" # => User.count: 0
DB構成
Role
DB操作ユーザとアプリケーション実行ユーザを分けて構成します。 DB操作ユーザはCreateDBという強い権限を持つためRLS policyが適用されないため、アプリケーション実行ユーザ(app_user)に必要最低限の権限を適用します。
論理名 | Role | Grant | 用途 |
---|---|---|---|
DB操作ユーザ | operator_user | SET ROLE(app_user) CREATEDB BYPASSRLS |
Migration Rspec |
Application実行 ユーザ |
app_user | SELECT INSERT UPDATE DELETE |
Application実行 |
RLS policy
- RLSの有効化
Policy適用対象のテーブルに対してRLSを有効にしpolicyを強制します。
ALTER TABLE #{table_name} ENABLE ROW LEVEL SECURITY; ALTER TABLE #{table_name} FORCE ROW LEVEL SECURITY;
- RLS policyの定義
Session変数current_setting('app.tenant_id')
に指定したtenant_idのみの絞り込みされる定義とします。
CREATE POLICY #{TENANT_POLICY_NAME} ON #{table_name} AS PERMISSIVE FOR ALL TO PUBLIC USING (tenant_id = NULLIF(current_setting('app.tenant_id'), '')::VARCHAR) WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id'), '')::VARCHAR)
Railsへの導入
DB migration
- table作成のmigrationでRLS policy適用処理実行
RLS policy適用対象のTableにはtenant_idを定義する必要があります。
def change create_table :users, id: false do |t| t.string :id, limit: 36, primary_key: true t.string :name, null: false, default: '' t.references :tenant, null: false, type: :string, foreign_key: { on_delete: :cascade } t.timestamps end reversible do |dir| dir.up do create_rls_policy(TABLE_NAME) end dir.down do drop_rls_policy(TABLE_NAME) end end end
- RLS policyの定義
def create_rls_policy(table_name) execute <<~SQL ALTER TABLE #{table_name} ENABLE ROW LEVEL SECURITY; ALTER TABLE #{table_name} FORCE ROW LEVEL SECURITY; SQL execute <<~SQL CREATE POLICY #{TENANT_POLICY_NAME} ON #{table_name} AS PERMISSIVE FOR ALL TO PUBLIC USING (tenant_id = NULLIF(current_setting('app.tenant_id'), '')::VARCHAR) WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id'), '')::VARCHAR) SQL end
- Tenant Switchの実装
tenant switchに関するメソッドを用意します。
tenant_idをsession変数を経由して指定するためのmethodを用意します。
def switch!(tenant_id) query = ActiveRecord::Base.sanitize_sql(["SET app.tenant_id = ?", tenant_id]) ActiveRecord::Base.connection.execute(query) end def reset! ActiveRecord::Base.connection.execute('RESET app.tenant_id;') end def current result = ActiveRecord::Base.connection.execute('SHOW app.tenant_id;') result.getvalue(0, 0) end
ApplicationRecordにBlock内でのみテナントを切り替えるmethodを実装します。
def self.with_tenant(tenant_id) RowLevelSecurity.switch!(tenant_id) yield ensure RowLevelSecurity.reset! end
Rspecへの導入
Rspecの実行権限
方針
rspecの実行はdb:migration、テストデータのクリアが必要になるためRLS policyがテスト実行の妨げとなります。
テストの実行全体はDB操作ユーザ(operator_user)で実行し、テスト実施自体は一時的にアプリケーション実行ユーザへ切り替える方針とします。
そのため以下の対応を行いRLS policyを適用した状態でテスト実施を行います。
- DB migration
- データの投入
- データのクリア
- テストコードの実行 (SET ROLEでアプリケーション実行ユーザへ権限切り替え)
コード
- test時のDB接続設定
# config/database.yml test: <<: *default database: rls_test username: operator_user
- test実施前後にROLEの切り替え
# spec/spec_helper.rb config.before(:each) do # Specの実行前にTest実行ユーザ(Migration可能なユーザ)からアプリケーション実行ユーザへ切り替え ActiveRecord::Base.connection.execute("SET ROLE 'app_user';") end config.after(:each) do ActiveRecord::Base.connection.execute("RESET ROLE;") end
DB migrationに関して
Rspec実行時に標準ではdb/schema.rbをもとにテスト用のDBが構築されるが、RLS policyはschema.rbの定義に含まれません。そのため以下の定義を無効化します。
ちなみに私はここでハマりまして原因特定に苦戦しました。
# spec/rails_helper.rb # begin # ActiveRecord::Migration.maintain_test_schema! # rescue ActiveRecord::PendingMigrationError => e # abort e.to_s.strip # end
代わりにrspec実行時に事前にmigrationが必要になります。
RAILS_ENV=test bundle exec rails db:migrate RAILS_ENV=test bundle exec rspec
環境毎のMigration実行
アプリケーション実行ユーザでmigrationできないため、環境ごとの実行をどうするか参考までに記載します。
この辺りはサービス毎に検討が必要です。
基本的な考えとしては先述の通り、アプリケーション実行とmigration実行のDBユーザを分けて実行するように設定します。
- development
- RAILS_ENV: development
- DATABASE_USER: operator_user
rails実行のコンテナ内でmigration実行します。
DATABASE_USER=operator_user bundle exec rails db:migrate
- production
- RAILS_ENV: production
- DATABASE_USER: operator_user
Deployフローで実行するなどアプリケーションサーバーと別環境で実行する様に構築します。
DATABASE_USER=operator_user
が指定される様に設定します。
bundle exec rails db:migrate
- test
- RAILS_ENV: test
- DATABASE_USER: operator_user
rails実行のコンテナ内でmigration実行します。
RAILS_ENV=test bundle exec rails db:migrate
サンプルコード
READMEに沿ってdockerを起動しRspecを実行して挙動を把握していただけます。