ENECHANGE Developer Blog

ENECHANGE開発者ブログ

PostgreSQLのRLSをRailsへ導入する際のポイント

こんにちは。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を実行して挙動を把握していただけます。

github.com