このエントリでは、マルチテナントアプリケーションを構築する場合に、テナントに紐づくデータをどう安全に扱うかという点でRLSについて記載します。 特に、1ユーザが複数テナントに関する権限を持つ場合にRLSをどう適用すべきか、頭を悩ませました。
- マルチテナントアーキテクチャ
- Row Level Securityとは
- 設定例から読み解くRLSの挙動
- CREATE POLICYから読み解く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標準の権限システムに加えて、通常の問い合わせでどの行が戻され、データ更新のコマンドでどの行を挿入、更新、削除できるかをユーザ単位で制限する行セキュリティポリシーをテーブルに定義できます。 この機能は行単位セキュリティとしても知られています。
実際のところ「返却」(SELECT
)に対してのみの設定でもありません。
自動的にWHERE条件を付与するという意味では、UPDATE
やDELETE
文に対しても適用できます。
また、自動的にCHECK
制約を付与する機能もあるので、UPDATE
やINSERT
に対して、「こういうレコードは作成してはいけない」設定も可能です。
従って、具体的には例えば以下のようなことがDBMSとして実現できます。
- テナントA用のDBユーザーに対しては、テナントAのデータしか返却しない (特定条件での自動フィルタリング)
- テナントA用のDBユーザーに対しては、テナントBのデータ作成を許容しない (自動でのCHECK制約)
- アプリ用DBユーザーに対して、「削除フラグ」が立っているデータを返却しない (特定条件での自動フィルタリング)
このうちマルチテナンシーの安全性を目的にRow Level Securityを利用する事例は多くあります。
- (AWS)PostgreSQL の行レベルのセキュリティを備えたマルチテナントデータの分離
- (HRBrain社)PostgreSQLのRow Level Securityを使ってマルチテナントデータを安全に扱う
- (FLINTERS社)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
カラムが接続ユーザーと一致するレコードのみを返却しろという設定です。
実際には「返却」だけでなく、そういうレコードのみをDELETE
やUPDATE
の対象とすること、および、そういうレコードだけを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ステップで進みます。
- ALTER TABLE ... ENABLE ROW LEVEL SECURITYでテーブルごとにRLSを有効化する
- 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つの観点で設定できます。
- (同一テーブルに対して複数のポリシー設定が可能なため)ポリシーをOR条件で適用するか、AND条件とするか
- CRUD全てに対してポリシーを適用するか、そのうちのどれかに対して適用するか
- どのロールに対するポリシーとするか
- フィルタ条件をどうするか
- 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接続ユーザーを切り替える必要があり、以下のような課題が出てくるでしょう。
- ユーザーの切り替えをライブラリがサポートしているのか
- コネクションプールの設計をユーザーごとに行う必要があるのか
テナントの情報を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') ));
どちらでも良いといえば良いのですが、変に複雑化しないので後者の方が望ましいかなと感じました。