理系学生日記

おまえはいつまで学生気分なのか

マルチテナントデータを安全に扱うための Row Level Security、そして1ユーザーが複数のテナントに所属するときの対応について

このエントリでは、マルチテナントアプリケーションを構築する場合に、テナントに紐づくデータをどう安全に扱うかという点でRLSについて記載します。 特に、1ユーザが複数テナントに関する権限を持つ場合にRLSをどう適用すべきか、頭を悩ませました。

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

SaaS Storage Strategiesによれば、マルチテナントアプリケーションを実現するためのアーキテクチャには以下のような種類があります。

  • Silo
  • Bridge
  • Pool

Poolを採用する場合気をつけなければならないのは、特定テナントのユーザに対し、他テナントの「見せるべきではない」データを返却しないことでしょう。 これは一般に、開発者に対して「テナントに関するWHERE条件を絶対に付与しろ」という不文律によって守られていきます。

このような「人間の努力」によって担保される「安全」は、多くの場合、人間の不完全性によって破られます。 システム的にこれをなんとかする方法がないものか。その1つの答えがRow Level Securityです。

Row Level Securityとは

Row Level Security(RLS)は、どのレコードを(DBユーザに)返却して良く、どのレコードはダメなのかをテーブルレベルで設定できるPostgreSQLの1機能です。 「自動で特定のWHERE条件を付与してくれる機能」あるいは「自動で特定条件でのフィルタリングを行う」機能と考えても良い。

GRANTによって利用できるSQL標準の権限システムに加えて、通常の問い合わせでどの行が戻され、データ更新のコマンドでどの行を挿入、更新、削除できるかをユーザ単位で制限する行セキュリティポリシーをテーブルに定義できます。 この機能は行単位セキュリティとしても知られています。

5.8. 行セキュリティポリシー

実際のところ「返却」(SELECT)に対してのみの設定でもありません。 自動的にWHERE条件を付与するという意味では、UPDATEDELETE文に対しても適用できます。 また、自動的にCHECK制約を付与する機能もあるので、UPDATEINSERTに対して、「こういうレコードは作成してはいけない」設定も可能です。

従って、具体的には例えば以下のようなことがDBMSとして実現できます。

  1. テナントA用のDBユーザーに対しては、テナントAのデータしか返却しない (特定条件での自動フィルタリング)
  2. テナントA用のDBユーザーに対しては、テナントBのデータ作成を許容しない (自動でのCHECK制約)
  3. アプリ用DBユーザーに対して、「削除フラグ」が立っているデータを返却しない (特定条件での自動フィルタリング)

このうちマルチテナンシーの安全性を目的にRow Level Securityを利用する事例は多くあります。

設定例から読み解くRLSの挙動

5.8. 行セキュリティポリシーから引用しますが、RLSの基本的な設定は以下のようになります。

CREATE TABLE accounts (manager text, company text, contact_email text);

ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;

CREATE POLICY account_managers ON accounts TO managers
    USING (manager = current_user);

これは、managersロールとしてPostgreSQLに接続した際、accountsテーブルのmanagerカラムが接続ユーザーと一致するレコードのみを返却しろという設定です。 実際には「返却」だけでなく、そういうレコードのみをDELETEUPDATEの対象とすること、および、そういうレコードだけをINSERTする制約にもなっています。 なお、current_userは「現在の実行コンテキストのユーザ名を返す」PostgreSQLの関数です。

ちょっと設定は異なりますが、具体例はPostgreSQL の行レベルのセキュリティを備えたマルチテナントデータの分離の内容がわかりやすいでしょう。

SaaSプロバイダーのシステムレベルのロールで(DBMSに)ログインした場合。

rls_multi_tenant=> SELECT * FROM tenant;
              tenant_id               |    name  | status | tier 
--------------------------------------+----------+--------+------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | Tenant 1 | active | gold
 69ad9212-f5ef-456d-a724-dd8ea3c80d61 | Tenant 2 | active | gold
(2 rows)

非システムユーザーのTenant 1ロールでデータベースにログインした場合。

rls_multi_tenant=> SELECT * FROM tenant;
              tenant_id               |    name  | status | tier 
--------------------------------------+----------+--------+------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | Tenant 1 | active | gold
(1 row)

CREATE POLICYから読み解くRLS

前章からわかるように、RLSはロールごと・テーブルごとの設定になります。設定は以下の2ステップで進みます。

  1. ALTER TABLE ... ENABLE ROW LEVEL SECURITYでテーブルごとにRLSを有効化する
  2. CREATE POLICYでRLSのポリシーを定義する

このうち、具体的なRLSの内容を設定するのがCREATE POLICYです。CREATE POLICYは以下にて定義されます。

CREATE POLICY name ON table_name
    [ AS { PERMISSIVE | RESTRICTIVE } ] -- OR 条件とするか AND 条件とするか
    [ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ] -- どの操作に対するポリシーとするか
    [ TO { role_name | PUBLIC | CURRENT_ROLE | CURRENT_USER | SESSION_USER } [, ...] ] -- ポリシーをどのロールに対して有効化するのか
    [ USING ( using_expression ) ] -- フィルタ条件
    [ WITH CHECK ( check_expression ) ] -- チェック制約

コメントでも示しましたが、以下の5つの観点で設定できます。

  1. (同一テーブルに対して複数のポリシー設定が可能なため)ポリシーをOR条件で適用するか、AND条件とするか
  2. CRUD全てに対してポリシーを適用するか、そのうちのどれかに対して適用するか
  3. どのロールに対するポリシーとするか
  4. フィルタ条件をどうするか
  5. CHECK制約をどうするか

マルチテナント安全性のための単純な設定例

主となる設定はフィルタ条件やCHECK制約でしょう。マルチテナントの安全性を担保すべくRLSを使うという前提に立つと、フィルタ条件の書き方はなかなか厄介です。

テナントごとにユーザーを切り替える

例えばAWSの例ではaccountsテーブルに対してRLSを適用していますが、user_nameカラムの値と「現在の実行コンテキストのユーザ名」が一致した場合、という条件にしています。 これにより、100テナントや1,000テナントであっても、この1ポリシーで担保できます。

CREATE TABLE accounts (manager text, company text, contact_email text);

ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;

CREATE POLICY account_managers ON accounts TO managers
    USING (manager = current_user);

マルチテナントでRLSを利用する多くの例は、上記のようにcurrent_userを使います。 一方で、この場合はテナントごとにDB接続ユーザーを切り替える必要があり、以下のような課題が出てくるでしょう。

  1. ユーザーの切り替えをライブラリがサポートしているのか
  2. コネクションプールの設計をユーザーごとに行う必要があるのか

テナントの情報をDBセッションあるいはDBトランザクションに持たせる

ユーザーの切り替えまではしたくない場合、テナントの情報をDBセッションあるいはDBトランザクションに持たせるという選択肢があります。 変数の設定はset_config関数で、取得はcurrent_setting関数で可能です。詳細は9.27. システム管理関数を参照ください。

例えばPostgreSQLのRow Level Securityを試すでは、以下のようなポリシーで実現されています。

-- DBに接続する際に set_configで'app.tenant_id'を設定した上で接続する
-- 設定された'app.tenant_id'とusers.tenant_idが同じレコードのみ操作出来るようになる
CREATE POLICY tenant_policy ON users
 USING(tenant_id = current_setting('app.tenant_id'));

つまり、アプリケーションにログインしたユーザーが所属するテナントさえ特定できれば、それをset_configによってDBセッションに設定し、 RLSで安全性を担保という戦略です。

ログインユーザが複数テナントの権限を持てる場合の対応

一方で、アプリのログインユーザが複数テナントの権限を持てるケースがあります。 例えば以下のようなERを前提とすると、アプリケーションの1ユーザは複数テナントに所属可能です。

このようなケースでは、テナントごとにDBユーザーを切り替えるという戦略は取れないということです。 また、ポリシーでUSING(tenant_id = current_setting('app.tenant_id'));というように指定する戦略も使えません。 なぜなら、set_configにはintやstringしか渡せず、複数の値を渡せないからです。 今回はこれに頭を悩ませました。

解決策には2案あります。

テナントIDのリストを文字列化

まず、アプリケーションからテナントIDのリストを作成し、セパレータ区切りの文字列としてset_configを呼び出します。 ここではPrismaを利用していることを前提としていますが、Raw SQLが作れるならどんなライブラリでも問題ないでしょう。

    const tenantIds = belongingTenants.map((tenant) => tenant.id).join(":");
    await prisma.$queryRaw`select set_config('app.tenantIDs', ${tenantIds}, false)`;

肝はポリシー設定で、複数案があります。

例えばbanksというテーブルがあった場合のポリシーは以下です。

CREATE POLICY tenant_policy ON banks
  FOR ALL -- CRUD 全てに適用
  TO app  -- アプリケーションがDBに接続するときのユーザ(=ロール)が`app`
  USING(tenant_id IN (
    SELECT unnest(string_to_array(current_setting('app.tenantIDs'), ':'))
  ));

string_to_arrayで文字列を配列に変換し、unnestでレコード列に変換しています。これら関数については9.19. 配列関数と演算子を参照ください。これにより、複数テナントの権限を持った場合であっても対応できます。

USINGの中でテナントIDを取得

こちらは、set_configには単にユーザーIDを保持するだけにとどめ、ポリシー側で当該ユーザーが所属するグループを導出する案です。

    await prisma.$queryRaw`select set_config('app.userID', ${userId}, false)`;
CREATE POLICY tenant_policy ON banks
  FOR ALL -- CRUD 全てに適用
  TO app  -- アプリケーションがDBに接続するときのユーザ(=ロール)が`app`
  USING(tenant_id IN (
    SELECT tenant_id
    FROM memberships m
    WHERE m.user_id = current_setting('app.userID')
  ));

どちらでも良いといえば良いのですが、変に複雑化しないので後者の方が望ましいかなと感じました。