RailsとPostgreSQLでRow Level Securityを使ったマルチテナント対応を行う
はじめまして。
株式会社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による行の特定を行うプールモデルを採用します。
この辺りの分離モデルについての話はこちらのスライドがわかりやすかったため、
どのモデルを採用すべきか検討する際は見ていただけたらと思います。
マルチテナント系の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文を実行しています。
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
をセットします。
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テーブルの一覧を取得する場合を例にすると以下のようになります。
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を使ったマルチテナント対応の実現方法でした。
ご紹介した内容がベストプラクティスかどうかは分からないため、
他に良い実現方法があれば是非コメントなどいただけますと幸いです。
参考記事
沢山ありますが、実装にあたり参考にさせていただいた記事です。
併せてご参考にしていただければと思います。
Discussion