RLSではじめるマルチテナントSaaS

こんにちは!Nstockの🥔jaga (永山大輔) です 。
NstockではマルチテナントSaaSを開発しており、テナント間のデータ分離にRow-Level Security(RLS)を利用しています。本記事ではRLSの基本から、Nstockでの利用イメージまで、SQL文やアプリケーションコードを交えて解説します。

備考

  • アプリケーションの実装イメージはSpring Bootですが、多くのフレームワークに存在する機能を利用しています
  • PostgreSQLのRLSについて話しています

マルチテナントアーキテクチャとRLS

Nstockは初期フェーズであり、人的リソースや金銭的リソースに余裕がありません。テナントごとに異なるDBサーバーやスキーマを用意するアーキテクチャは、リソース的に厳しいです。そのため、複数のテナントでDBサーバーを共有しつつ、 カラムを用いてテナント間のデータを分離することにしました。しかし、毎回手動でWHERE句にテナント分離用の条件を追加するのは作業負荷が大きいですし、指定し忘れた場合のデータ漏洩も怖いです。
 
 
WHERE句を自動的に追加する方法として、ウェブフレームワークの機能や、DBのRow-Level Security(RLS)が考えられます。Nstockではより低いレイヤーでこの問題を解決するのが望ましいと考え、RLSを採用しました。
RLSは、DBテーブルの各行に対するアクセスを制御するための機能で、PostgreSQLなどいくつかのDBで利用できます。RLSを使用すると、あるテーブルの行に対して特定のユーザーのみがアクセスできる制約を設けることができます。

RLSをハンズオン形式で学ぶ

以下ではPostgreSQL14.5を利用してRLSの利用イメージを膨らませていきます。ちなみにMySQLでは今のところサポートされていません。
💡
RLSはPostgreSQL9.5から導入されました
💡
正確にはPostgreSQLのRLSはRow Security Policyという機能名ですが、本記事ではより一般的な呼称であるRLSを用います [1]

RLSポリシーの書き方

RLSを有効化するには以下の構文を利用します。
 
 
  • : RLSポリシーの名前です。テーブル毎にユニークである必要があります。
  • : RLSポリシーを適用する対象のテーブル名です。
  • : RLSポリシーが適用されるコマンドです。デフォルトは で、全てのコマンドに適用されます。
  • : RLSポリシーが適用されるロールです。 デフォルトはで、すべてのロールに対してポリシーが適用されます。
  • : の際にどの行が見えるか(可視性)をSQL条件式で指定します。
  • : テーブルに追加・更新される行に対し、 可視性の条件(句)とは異なるポリシーを使用したい場合に利用します。 されたあとのレコードがこの条件式を満たすことを保証します。
 
まとめるとRLSポリシーは大きく以下の2つのパートに別れます。
 
  1. RLSポリシーを適用する問い合わせの条件 ( )
  1. 1.を満たす問い合わせに自動で追加する条件式 ( )
 
1. を満たすDB問い合わせに 2. の条件式が自動で追加される、と考えるとわかりやすいです。

RLSポリシーでマルチテナントを分離する

RLSでマルチテナント分離を行う場合、USING句に分離する条件式を書くことになります。私の調査した範囲では、主に以下の2つの変数が条件式が利用されていました。
 
  1. ロール名
  1. 実行時パラメータ
 
以下ではこの2つの方法を比較します。

条件式のパターン 1. ロール名

DBロール名を参照して条件式を書きます。まずはテーブルを用意します。
 
 
現状はRLSを設定していないため、すべてのレコードを見ることが可能です。
つぎに 、すなわちDBに問い合わせを行うDBロールの名前と、 が一致する場合のみ閲覧や操作ができる、というRLSポリシーを追加します。
 
 
セクションに、追加した の存在が確認できます。
ロールを使って テーブルを参照してみましょう。
 
 
となるレコードはないため、0件のレコードが返ります。
次に ロールを使ってアクセスしてみます。
 
 
である1件のレコードが返ります。このように、ロール名を使ったRLSポリシーでマルチテナントのデータ分離が可能です。
しかし、この方法でマルチテナントのデータを分離する場合、テナントの数だけ DBロールが必要になってしまいます。プロダクトがスケールするにつれ、DBロールの管理が複雑になるのは目に見えており、それは避けたいです。
また、ロール名含むSQLの識別子には英数字、アンダースコアしか利用できず[2]、UUIDフォーマットは利用できません。Nstockではセキュリティ観点からIDとなるカラムにはUUID型を採用しているため、この点でもフィットしませんでした。

条件式のパターン 2. 実行時パラメータ

もう一つの方法は実行時パラメータを参照する方法です。まずはテーブルを用意します。
 
 
現状はRLSポリシーを設定していないため、すべてのレコードを見ることが可能です。
つぎに、実行時パラメータを利用したRLSポリシーを作成します。
 
 
  • を使い実行時パラメータを取得し、USING句の条件式に利用します
  • テーブルの フィールドは 型なので、 型である実行時パラメータの値 を使ってキャストしています。
 
実際にこのポリシーを使用してみましょう。
 
 
実行時パラメータは が発行されたDBセッション内でしか利用されず、他のセッションからは参照されません。セッションが閉じた際に実行時パラメータも破棄されます。

Nstockではどちらを採用したか

パターン2.の実行時パラメータを利用した方式では、パターン1.でみたロール名を利用したRLSポリシーと異なり、DBロールの管理が必要ありません。そのため、Nstockでは実行時パラメータを利用したRLSポリシーを採用しています。
💡
ちなみに、RLSポリシーの名前はテーブル毎に一意であれば良いため、以下のRLSポリシーは併存できます。テナント分割用のRLSポリシーのように、同じ役割を持つポリシーは同じ名前にすることで、あとから見返したときに役割がわかりやすくなります。

アプリケーションからの呼び出し

アプリケーションの実装イメージについてお話します。三角マークをクリックすると、Spring Bootのモック実装が見れます。Springを使ったことがない方も、なんとなくの流れを感じ取っていただければ幸いです。
1. リクエストごとに分離された、 を格納する変数を用意する
  • ThreadLocalクラスを使用すると、スレッドごとに独立した値が保存される変数を格納することができる [3]
  • Spring Bootの場合、リクエストごとにスレッドが立ち上がるため、 1リクエスト = 1スレッドとなる [4]
  • これにより、SpringではThreadLocalのインスタンスをリクエストごとに分離された変数の保管場所として利用できる
2. リクエストの前処理でリクエストヘッダから を取り出し、ストレージにセットする
3. SQLのコネクションを張る際に呼び出される関数で、1. の変数から を取り出し実行時パラメータにセットする
  • getConnectionはDBアクセスが走るたびに呼び出されれる [5]
  • 4. 後はRLSのことを意識せず、フレームワークのORMなどでDBアクセスする👌

RLSの注意点

マルチテナントアーキテクチャを実現する上でたいへん役に立つRLSですが、いくつか注意しなければならない点があります。他にもこれ気をつけたほうがいいよ!というアドバイスお待ちしてます🙏

注意点 1. RLSポリシーが適用されないロールがある

デフォルトでは、テーブルの所有者は RLS ポリシーを無視したアクセスが可能です。そのため、テーブルを作成するDBロールとRLSを利用するテーブルロールは分割しましょう。テーブルの所有者に対してもRLSポリシーを適用するには  を利用します。
 
 
Nstockの場合、DBマイグレーションを行うDBロールと、アプリケーションでつかうDBロールを分けています。これによって、テーブルの所有者にRLSポリシーが適用されない問題を回避して、アプリケーション用のDBロールにRLSポリシーを適用しています。
 
💡
ちなみに、スーパーユーザーや、  属性を使用して作成したロールにも、RLSポリシーが適用されません。ただし、スーパーユーザーはセキュリティ上アプリケーションで使わないほうが安全ですし、RLSポリシーを適用するアプリケーション用DBロールに 属性を設定することもないので、あまり気にしなくて良さそうです。

注意点 2. RLSポリシーを設定してもインデックスは自動で貼られない

RLSポリシーを設定すると、USING句で指定したカラムを使った問い合わせが頻繁に走ることになります。しかしながらRLSポリシーを作成してもインデックスは自動で貼られません。そのため、RLSポリシーを追加する場合は、インデックスも併せて検討する必要があります。
 

注意点 3. RLSポリシーの設定を誤ると一大事

アクセス権限の管理をRLSに集約することで、変更・確認する箇所を減らすことができるのは、低レイヤーで行レベルアクセス制御が実現できるRLSの利点でしょう。一方でアクセス権限の管理がRLSに集約される分、RLSの設定を誤ると一大事です。
Nstockではプルリクエストのレビュー項目に「RLSポリシーが適切に設定されているか」をいれたり、月に一度のエンジニア定例作業のなかで、RLS設定の棚卸ししたりしています。RLSの設定内容を確認する自動テストも今後追加していく予定です。

さいごに

RLSはいいぞ
次回はB2B2E SaaSにおけるRLSを使ったDB設計についてお話しようと思います。これからもRLSの試行錯誤について発信していくので、よかったらまた見に来てください 👋
最後に、Nstockではエンジニアを募集しています!スタートアップを皮切りに日本を変えていきませんか?
カジュアル面談から気軽にお話しましょう🤞
脚注
[4] Spring BootではServletコンテナとして、Tomcat、Jeety、Undertowをサポートしています(デフォルトはTomcat)[6] 。Servletコンテナでは各HTTPリクエストはリクエストごとにスレッドが立ち上がります[7]。ただし、リアクティブプログラミングモデル(Spring WebFlux)を利用する場合、Servletコンテナの代わりにReactorが使われます。この場合、1スレッドで複数のリクエストが処理される可能性があります。このように、Spring Bootを利用していても1リクエスト=1スレッドとはならないケースがあります。↩️
[5] Spring Frameworkでは、データベース接続は通常、トランザクション単位で管理されます。つまり、トランザクションが開始されるときにデータベースコネクションが取得され、トランザクションが終了(コミットまたはロールバック)されるときにコネクションがリリースされます[8]↩️