🏬

RailsとPostgreSQLでRow Level Securityを使ったマルチテナント対応を行う

2023/12/02に公開

はじめまして。
株式会社Sun Asteriskでバックエンドエンジニアを担当している三浦大輝(みうら だいき)
と申します!

Sunには2023年10月からお世話になったばかりですが、
アドベントカレンダーで執筆するという憧れがあったので早速ですが参加させていただきました。

現在、バックエンドにRailsを用いたWebアプリの開発プロジェクトに参画しています。
そこでRow Level Securityを扱うこととなったので、
その際に得た知見をアウトプットするという目的で書かせていただきます。

検証環境

  • Ruby 3.2.2
  • Ruby on Rails 7.1.1
  • PostgreSQL 15.3

上記環境をdocker composeで構築しています。

話すこと

  • Row Level Securityの使い方
  • RailsでRow Level Securityを扱う方法

話さないこと

  • マルチテナントとは
  • Row Level Securityのメリット・デメリット
  • Row Level Securityを採用することによるパフォーマンスへの影響
  • データベースの分離モデルについて

前提

パフォーマンスについて

パフォーマンスは今回重要視されるものではなかったため、
その点については特に考慮しておりません。

テーブルの肥大化によるパフォーマンスの懸念などはもちろんあるため、
プロジェクトに応じて導入するかどうかを精査してください。

採用する分離モデルについて

今回はデータベースやスキーマは分けず、
テナントIDによる行の特定を行うプールモデルを採用します。

この辺りの分離モデルについての話はこちらのスライドがわかりやすかったため、
どのモデルを採用すべきか検討する際は見ていただけたらと思います。

https://www.slideshare.net/AmazonWebServicesJapan/20220107-multi-tenant-database

マルチテナント系のgemについて

今回、マルチテナント系のgemは使いません。

ブリッジモデルであれば「apartment」、
プールモデルであれば「activerecord-multi-tenant」と「activerecord-tenant-level-security
などは調査時に候補として検討しましたが、今回の要件には合わなかったため導入は見送りました。

Row Level Securityとは

それでは本題に移ります。

早速ですがRow Level Security(※以下RLSとする)とは、
ユーザーごとにデータベースを行レベルでアクセス制御する仕組みのことです。

例えば、1つのサービスを複数のテナント(ユーザー)が共有している状態を想像してください。

データの中には自分以外には見えてはいけないデータがあると思いますので、
通常であればアクセスの際にWHERE句などを用いて適切なデータを取得するようにします。

しかしこれではヒューマンエラーなどにより実装漏れが発生した際に
重大なセキュリティ事故を発生させる可能性があります。

そこでRLSを導入することで、
アプリケーションの安全性を高めることができます。

Row Level Securityを扱ってみる

それでは一旦Railsは置いておき、まずはRLSを扱ってみることで、
必要な処理についてのイメージを作っていきたいと思います。

RLSを使ってみる

さて、RLSを使うにはざっくりと以下の手順を踏む必要があります。

  • テーブルの作成(テナントIDのカラムを用意)
  • ROLEの作成
  • ROLEへ権限の付与
  • POLICYの作成
  • RLSを有効化する
  • ROLEの切り替え
  • テナントIDをSETする
  • クエリを実行する

それでは例を交えながら1ステップずつ見ていきましょう。

テーブルの作成(テナントIDのカラムを用意)

今回はサンプルとしてtenantsテーブルとbooksテーブルを用意します。
tenantsにはその名の通りテナント(ユーザー・顧客)が入ると考えてください。

ここでのポイントは、booksテーブルにあるtenant_idカラムです。
booksテーブルには色々なテナントのデータが混在することになるので、
このカラムを見てデータを分離することになります。

つまり、テナントごとに分離する必要のあるデータを持つテーブルには
必ずこのtenant_idカラムが必要になります。

tenants
tenant_name
books
title
author_name
tenant_id

ROLEの作成

それでは、次にROLEを作成していきます。

まず前提としてSuper userにはBypass RLSという権限があり、
RLSを無視できる」という点に注意しましょう。

つまり、Super userのままでは設定を行いクエリを実行してもRLSは効きません。
そのため、まずはSuper user以外の新しいROLEを作成する必要があります。

それでは以下のコマンドで新しいROLEを作成してみましょう。

CREATE ROLE new_role with LOGIN;

これでnew_roleという新しいROLEができたはずです。
次のコマンドで確認してみましょう。

\du

新しいROLEが追加されていることが確認できると思います。

ROLEへ権限の付与

では、新規作成したROLEへ権限付与を行います。
ROLEを切り替える前に以下のコマンドを実行しましょう。

GRANT SELECT, UPDATE, INSERT, DELETE ON ALL TABLES IN SCHEMA public TO PUBLIC;
GRANT SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO PUBLIC;

POLICYの作成

次に、POLICYという「テーブルごとに適用するルール」を作成します。
POLICYは次のコマンドで作成できます。

CREATE POLICY ポリシー名 ON テーブル名 FOR ALL USING (tenant_id = current_setting('tenant.id'));

例えばbooks_isolate_policyというポリシーをbooksテーブルに作成する場合は
以下のようになります。

CREATE POLICY books_isolate_policy ON books FOR ALL USING (tenant_id = current_setting('tenant.id'));

正常にPOLICYが作成されていれば、\d booksの結果は次のようになっているはずです。

Policies (row security disabled):
    POLICY "books_isolate_policy"
      USING (((tenant_id)::text = current_setting('tenant.id'::text)))

RLSを有効化する

POLICYを作成したら、次はそのテーブルに設定されたRLSを有効化する必要があります。
先ほど確認した内容を見ると、row security disabledとあるのがわかります。

これを有効化(Enable)しないといけないので、次のコマンドを実行してください。

ALTER TABLE books ENABLE ROW LEVEL SECURITY;

これでもう一度先ほどのコマンドを実行したら、
row security disabledの文言が消え、Enableになっていることが確認できると思います。

ROLEの切り替え

それでは、RLSが適用されるかどうかを確認するため先ほど作成したROLEに切り替えましょう。
(先述のように、superuserのままではRLSは適用されないためです)

SET ROLE 'new_role';

ROLEが切り替えできているか、念の為確認しましょう。
切り替えたROLE名になっていれば切り替えは完了です。

select current_user;

テナントIDをSETする

そして、先ほど作成したPOLICYの
tenant_id = current_setting('tenant.id')部分であったように、
検索条件として付与するテナントIDを設定をします。

SET tenant.id = テナントID;

クエリを実行する

長旅でしたが、あとはこれでSQLを実行するだけです。
以下を実行するだけで暗黙的にWHERE句が付与された状態で実行されます。

SECET * FROM books;

RailsでRLSを実現する

さて、ようやく本題のRLSをRailsで実現する方法についてご紹介します。
大きく分けて以下のイメージで実行していきます。

migrationで実行する

  • POLICYの作成
  • RLSを有効化する

テーブル追加の際にPOLICYの作成とRLS有効化はマストですので
migrationのタイミングで行うべきだと判断しました。

seedで実行する

  • ROLEの作成(ROLEがない場合)
  • ROLEへ権限の付与

これらは一度だけ実行すればいいですし、
初期データの投入などはseedで行うためseedが良さそうだと判断しました。

seedを使わないという場合はDBに入り直接コマンドを実行するという方法でもいいと思います。

Controllerで実行する

  • ROLEの切り替え(before_action)
  • テナントIDをSETする(before_action)
  • クエリを実行する

実際にリクエストをする際にRLSを適用するかどうかを分岐させたいので、
Controllerのbefore_actionなどで実行するのが適切と判断しました。

ROLEの作成

ROLEを作成するため、以下のSQLをRailsから直接実行します。
以下では「ROLEの作成」と「ROLEへ権限の付与」を行っています。

ここではヒアドキュメントという書き方を使い、複数行のSQL文をsql_queryに入れています。

その後、ActiveRecord::Base.connection.execute(sql)sql_queryに入れたSQL文を実行しています。

seeds.rb
sql_query = <<~SQL
  CREATE ROLE new_role with LOGIN;
  GRANT SELECT, UPDATE, INSERT, DELETE ON ALL TABLES IN SCHEMA public TO PUBLIC;
  GRANT SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO PUBLIC;
SQL
ActiveRecord::Base.connection.execute(sql_query)

POLICYの作成、RLSを有効化する

先程まとめたように、POLICYの作成とRLSの有効化はmigrationで行います。
booksテーブルを作成すると仮定した場合、以下のようにします。

class CreateBooks < ActiveRecord::Migration[7.1]
  def change
    create_table :books do |t|
      t.string :title
      t.string :author_name
      t.string :tenant_id

      t.timestamps
    end

    # 以下で「RLSを有効化する」と「POLICYの作成」のSQLを実行する
    execute <<-SQL
      ALTER TABLE books ENABLE ROW LEVEL SECURITY;
      CREATE POLICY books_isolate_policy ON books FOR ALL USING (tenant_id = current_setting('tenant.id')::BIGINT);
    SQL
  end
end

ROLEの切り替え、テナントIDをSETする

tenant_idについてはapplication_controllerなどでインスタンス変数、もしくはセッションに入れ保持しておくといいと思います。
books_controllerのbefore_actionでtenant_idをセットします。

books_controller.rb
class BooksController < ApplicationController
  before_action :switch_role

  def switch_role
    ActiveRecord::Base.connection.execute("SET ROLE 'new_role';")
    ActiveRecord::Base.connection.execute("SET tenant.id = #{@tenant_id};")
  end
end

クエリを実行する

あとは普通にクエリを実行するだけです。
booksテーブルの一覧を取得する場合を例にすると以下のようになります。

books_controller.rb
def index
  books = Book.all
  render json: { books: }
end

DBは以下のようにデータを準備しました。

tenants

tenant_name
テナント_1
テナント_2

books

title author_name tenant_id
タイトル_1 著者_1 1
タイトル_2 著者_2 2
タイトル_1-2 著者_1 1

では、クエリの実行結果を見てみましょう。
WHERE句をつけなくても、Book.allだけでtenant_idが1のレコードだけが取得されていることがわかります。

終わりに

以上、RailsでRLSを使ったマルチテナント対応の実現方法でした。

ご紹介した内容がベストプラクティスかどうかは分からないため、
他に良い実現方法があれば是非コメントなどいただけますと幸いです。

参考記事

沢山ありますが、実装にあたり参考にさせていただいた記事です。
併せてご参考にしていただければと思います。

https://www.slideshare.net/AmazonWebServicesJapan/20220107-multi-tenant-database

https://aws.amazon.com/jp/blogs/news/multi-tenant-data-isolation-with-postgresql-row-level-security/

https://zenn.dev/bitarts/articles/5ec0fe8f8cf77c

https://times.hrbrain.co.jp/entry/postgresql-row-level-security

https://tech.smarthr.jp/entry/2022/02/15/202241#fn-c8703ab8

Sun* Developers

Discussion