🐕

RLS を使ったマルチテナントの実装

2024/02/26に公開

はじめに

SaaS サービスを提供する際、テナントのデータをどのように管理するかは重要な課題です。
セキュリティーの観点から、テナントごとにデータを分離する必要があります。

例えば以下のようなアプローチが考えられます。

  1. アプリケーションとデータベースをテナントごとに分離する
  2. アプリケーションを共通で使い、データベースをテナントごとに分離する
  3. アプリケーションとデータベースを共通で使い、データをテナントごとに分離する

それぞれトレードオフがありますが、この記事では 3 のアプローチ、いわゆるマルチテナントのアーキテクチャーを PostgreSQL の RLS を使って実現する方法を紹介し、その注意点について共有します。

マルチテナントとそのリスクについて

マルチテナントとは、複数のテナントが共通のアプリケーションを使い、共通のデータベースにデータを保存するアーキテクチャです。
マルチテナントを採用する場合、データベースのテーブルに tenant_id というカラムを追加し、WHERE tenant_id = ? という条件を付与することで、自分のテナントのデータのみ操作できるようにすることが多いです。
しかしながら、この構成においてはヒューマンエラーやバグにより他のテナントのデータを操作できてしまうリスクがあります。

この記事では PostgreSQL の RLS と呼ばれる機能を使い、他のテナントのデータを操作できないようにする仕組みを紹介します。

Row Level Security (RLS) について

RLS は、テーブルの行に対してアクセス制御を掛けることができる機能です。PostgreSQL や有力な商用データベースのいくつかで利用可能ですが、この記事では PostgreSQL の RLS をベースに説明します。

素朴にマルチテナントアプリケーションを実装する場合、SELECT user.* FROM user WHERE user.tenant_id = ? のような SQL を発行することになります。

RLS を使って SELECT user.* FROM user とした場合、RLS によって WHERE tenant_id = ? が自動的に付与され、自分のテナントのデータしか操作できなくなります。
この仕組みによって、アプリケーション側で WHERE 句を付けなかったとしても、他のテナントのデータを操作することはできません。(付けてもよいです)

テナントIDをどうやって指定するかですが、現時点では2つの方法があります。

  1. テナントのデータにアクセスできるユーザーを作成し、テナントごとに接続するユーザーを切り替える
  2. ランタイムパラメーターにテナントIDを指定し、クエリー実行時にテナントIDを読み取る

共通のアプリケーション内であれば、ランタイムパラメーターのほうが利用しやすいですし、運用、保守の面でも有力と私は考えます。
本記事ではランタイムパラメーターを使う方法を紹介します。

RLS の使い方

ランタイムパラメーターを使った RLS の使い方について説明します。
具体例としてテナントに紐づくユーザーテーブルがあり、ユーザーを RLS で制御することを目指します。
最初にテーブルを作成します。

CREATE TABLE user (
    user_id uuid NOT NULL PRIMARY KEY,
    tenant_id uuid NOT NULL
);

次にテーブルに RLS を有効にします。

ALTER TABLE user ENABLE ROW LEVEL SECURITY;

続いて具体的なセキュリティーポリシーを作成します。

CREATE POLICY user_isolation_policy ON user
    USING (tenant_id = current_setting('app.tenant_id')::UUID);

USING 句が true を返した場合、その行が可視状態となります。
current_setting() は PostgreSQL の関数で、引数のランタイムパラメーターの値を返します。
この USING 句では app.tenant_id というランタイムパラメーターから値を取得し、user.tenant_id と照合する定義を書いています。

なお、Policy については許可する操作、可視の条件、レコードの追加の条件などを設定することができます。詳細は PostgreSQL の公式ドキュメントの 行セキュリティーポリシー を参照してください。

ランタイムパラメーターの設定は以下のように行います。

SELECT set_config('app.tenant_id', '00000000-0000-0000-0000-000000000000', true);

ランタイムパラメーターを設定後、通常通りクエリーを発行すれば、RLS によって WHERE tenant_id = ? が自動的に付与された状態になり、自分のテナントのデータのみ操作できるようになります。

なお RLS はすべてのユーザーに有効というわけではありません。
スーパーユーザーや RLS を無視するアトリビュートを保つユーザー、テーブルの所有者には適用されません。

注意点

もしあなたが RLS を使う際には、注意点の章をぜひご確認ください。

RLS が正しく運用されなかったときの挙動

RLS が正しく運用されなかったとき、つまり何らかのミスをしたときはどうのような挙動について列挙します。

  • ランタイムパラメーターの設定を忘れると、current_setting()ERROR: unrecognized configuration parameter "{parameter_name}" というエラーを返す
  • Policy に違反した操作を行った場合、ERROR: new row violates row-level security policy for table "{table_name}" とエラーを返す
  • RLS が有効だが適用可能な Policy がないテーブルは、すべての行が不可視状態かつ更新不可状態になる
  • 使用しているユーザーがスーパーユーザー権限を持っていたり、RLS を無視するアトリビュートを保つユーザーである場合、RLS が無効――つまりすべての行が可視化状態かつ更新可能状態になります

RLS の設定を忘れないための方法

あたりまえではありますが、RLS の設定を忘れた場合、すべての行が可視化状態かつ更新可能状態になってしまいます。
本来設定すべきテーブルに RLS が設定されいないケースを検知して防ぐ必要があります。

テストで DB の状態を検証することもできます。
もちろん、アプリケーションを実行時に検証するのも良いでしょう。

各テーブルの RLS の設定は以下のクエリーで確認できます。
RLS を設定してはいけないテーブルもありますから、それらを除外する必要もあります。

SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'your-application-schema';

仮に Policy が未設定の場合、前述の通りすべての行が不可視状態かつ更新不可状態になるため、他の DB を使ったテストは通らないし、すべての環境で動かなくなるから、本番リリース前に気がつくはずです。

RLS が有効なユーザーであることを確認する

RLS はスーパーユーザー、テーブルの所有者、RLS を無視するアトリビュートを保つユーザーには適用されません。
アプリケーションユーザーがそれらのユーザーでないことを確認する必要があります。

これはアプリケーションを実行時に検証するのが良いと考えます。
もしアプリケーションユーザーが RLS が有効でないなら、アプリケーションは動いてはいけません。

以下のクエリーで usesuper, usebypassrls のいずれも false であることを確認しましょう。

SELECT usename, usesuper, usebypassrls
FROM pg_catalog.pg_user
WHERE usename = current_user;

また、以下のクエリーで current_user とテーブルの所有者が異なることも確認しましょう。
そもそもマイグレーションユーザーとアプリケーションユーザーを分けていて、アプリケーションユーザーには CREATE TABLE 権限を与えていなければ、この検証は不要としてもよいでしょう。

SELECT tablename, tableowner
FROM pg_tables
WHERE schemaname = 'your-application-schema';

テナントをまたいだ操作を行い対場合の対策

全テナントのデータを操作するユースケースがある場合、RLS とどう折り合いを付けるのがよいでしょうか?
この悩みは管理画面、バッチ処理、データ分析などのユースケースで発生することがあります。
これは難しい問題です。

いくつかの方法が考えれられます。

  1. RLS を使わない
  2. RLS を無視するユーザーを使う

1 を直接採用するのはとてもリスキーです。
ただし、テーブル設計を見直し、一般ユーザーが使用するテーブルには RLS を使い、テナントをまたいだ操作を行うユースケースが使用するテーブルには RLS を使わないようにする工夫ができる場合はよい選択になります。

ただ、そううまくテーブルを分離できない場合はどうするのがよいでしょうか?
その場合は 2 を採用しつつ、操作可能なテーブルを制限するのが現実的な選択になります。

例えば管理画面からはテナント横断で操作可能にするという要件があったとき、以下のような方法が考えられます。

  • 一般ユーザーのアプリケーションと管理画面のアプリケーションを分離する
  • 管理画面からの DB の操作は RLS を無視するユーザーを使う
  • 管理画面から操作可能なテーブルとテーブルに対する権限を GRANT で制限する

ただし、この問題に対する答えはアプリケーションの要件によって変わる可能性が高いです。
もっとよいアイディアをお持ちだったり、同じように悩んだ方がいたら、ぜひコメントで考えを教えていただけますと幸いです。

その他

明示的にテナントIDを指定してもよいか

RLS の条件に重複して WHERE tenant_id = ? を書いても期待通りに動作します。
その方が仕様を理解しやいですし、もし別の RLS のない DB に移行したとしてもコードの修正を少なくできます。
そういった観点から明示的に指定すべしと私は考えます。

おわりに

SaaS を提供する際、テナントのデータをどのように管理するかはとても重要であり、ほぼすべての SaaS を提供する会社で同じ問題に直面します。
トレードオフを考慮すると、多くの場合はマルチテナントを選択しているのではないでしょうか。
マルチテナントをセキュアーに運用するためには、RLS は有効なアプローチになります。

ただし、RLS の導入は手間ですし、使い方を間違えたときの挙動は調べたり検証しないと分からないことも多いです。
私自身、RLS を使ったアプリケーションを作ったとき、様々な調査や検討が必要でした。

また RLS を運用したときの注意点も多いです。RLS とミスマッチな要件もあるでしょう。
今回は私の経験を元に注意点や設計面で悩んだことを共有させていただきましたが、要件により答えが変わる可能性が高いです。
多くの会社で同じような悩みを抱えていると思うので、ぜひ RLS を使った知見がもっと増えることを願っています。

Discussion