マルチテナントを実現するときに便利な機能Row Level Security
テーブルごとにアクセス可能な行を絞ることができるんですが、設定例を調べると単一テナントを前提としたものが多く
複数のテナントのデータにアクセスさせる設定がわからなかったので試してみました。
設定例
以下のようなクエリで実現できました。
CREATE POLICY tenant_isolation_policy ON users USING (tenant_id = ANY( SELECT tenant_id FROM user_tenants WHERE user_id = current_setting('app.current_user_id')::UUID) );
USING句がbooleanを返す行にのみアクセスできる仕組みなので、ANYを使って複数のテナントに対応しました。
テーブル定義
userが所属する対象としてtenant
複数のtenantに所属させるために、user_tenantsという中間テーブルを作ります。
この記事ではusersテーブルに対し、RLSを設定し「自分が所属しているテナントのuser」のみ閲覧できるよう設定していきます。
create table tenants ( id uuid default uuid_generate_v4() not null primary key, name varchar(255) not null unique ) create table users ( id uuid default uuid_generate_v4() not null primary key, tenant_id uuid not null references tenants on delete restrict, name varchar(255) not null unique ); create table user_tenants ( id bigserial constraint user_tenants_pk primary key, user_id uuid not null references users on delete restrict, tenant_id uuid not null references tenants on delete restrict );
データ作成
テナントとユーザーを3つずつ。ユーザー1だけ、テナント1と2の2つに所属させます。
INSERT INTO tenants VALUES(uuid_generate_v4(), 'テナント1'); INSERT INTO tenants VALUES(uuid_generate_v4(), 'テナント2'); INSERT INTO tenants VALUES(uuid_generate_v4(), 'テナント3'); INSERT INTO users VALUES(uuid_generate_v4(), (SELECT id FROM tenants WHERE name = 'テナント1'), 'ユーザー1'); INSERT INTO users VALUES(uuid_generate_v4(), (SELECT id FROM tenants WHERE name = 'テナント2'), 'ユーザー2'); INSERT INTO users VALUES(uuid_generate_v4(), (SELECT id FROM tenants WHERE name = 'テナント3'), 'ユーザー3'); INSERT INTO user_tenants (user_id, tenant_id) VALUES((SELECT id FROM users WHERE name = 'ユーザー1'), (SELECT id FROM tenants WHERE name = 'テナント1')); INSERT INTO user_tenants (user_id, tenant_id) VALUES((SELECT id FROM users WHERE name = 'ユーザー1'), (SELECT id FROM tenants WHERE name = 'テナント2')); INSERT INTO user_tenants (user_id, tenant_id) VALUES((SELECT id FROM users WHERE name = 'ユーザー2'), (SELECT id FROM tenants WHERE name = 'テナント2')); INSERT INTO user_tenants (user_id, tenant_id) VALUES((SELECT id FROM users WHERE name = 'ユーザー3'), (SELECT id FROM tenants WHERE name = 'テナント3'));
当たり前ですが、この時点ではusersテーブルは全て閲覧可能です。
=> SELECT * FROM users; id | tenant_id | name --------------------------------------+--------------------------------------+----------- aa69a980-fa2c-43b5-aeb8-e968fd6fc68e | 0389694d-ab45-47c4-97a4-70b7db851c7b | ユーザー1 8fb6bf75-031c-4f62-920f-20e062b7a00d | 00eccee2-6cf7-48ad-a21d-4170a13f6b90 | ユーザー2 9d90c1f2-92a5-4c52-b0f2-a9a6398eef64 | 3b5dc2e3-4e11-4d5a-a633-52c128682188 | ユーザー3 (3 rows)
RLS有効化
冒頭で紹介したクエリでRLSを有効に。
'app.current_user_id' な設定値としてusersテーブルのPKを渡すことで、user_tenantsテーブルを参照し
そのユーザーが所属するテナントのデータだけを閲覧可能にする設定です。
ALTER TABLE users ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_policy ON users USING (tenant_id = ANY( SELECT tenant_id FROM user_tenants WHERE user_id = current_setting('app.current_user_id')::UUID) );
結果
ユーザー1のみ2つのテナントに所属しているため、テナント1と2両方のユーザーがSELECTできます。
-- テナント1と2に所属するユーザー1の場合 SELECT set_config('app.current_user_id', 'aa69a980-fa2c-43b5-aeb8-e968fd6fc68e', false); SELECT * FROM users; id | tenant_id | name --------------------------------------+--------------------------------------+----------- aa69a980-fa2c-43b5-aeb8-e968fd6fc68e | 0389694d-ab45-47c4-97a4-70b7db851c7b | ユーザー1 8fb6bf75-031c-4f62-920f-20e062b7a00d | 00eccee2-6cf7-48ad-a21d-4170a13f6b90 | ユーザー2 (2 rows) -- テナント2に所属するユーザー2の場合 SELECT set_config('app.current_user_id', '8fb6bf75-031c-4f62-920f-20e062b7a00d', false); SELECT * FROM users; id | tenant_id | name --------------------------------------+--------------------------------------+----------- 8fb6bf75-031c-4f62-920f-20e062b7a00d | 00eccee2-6cf7-48ad-a21d-4170a13f6b90 | ユーザー2 (1 row) -- テナント3に所属するユーザー3の場合 SELECT set_config('app.current_user_id', '9d90c1f2-92a5-4c52-b0f2-a9a6398eef64', false); SELECT * FROM users; id | tenant_id | name --------------------------------------+--------------------------------------+----------- 9d90c1f2-92a5-4c52-b0f2-a9a6398eef64 | 3b5dc2e3-4e11-4d5a-a633-52c128682188 | ユーザー3 (1 row)
ハマったポイント
検証時、単一のDBユーザーでテーブル作成やRLSの設定をしていたのですが、set_configをしてもRLSによるレコードの絞り込みが行われず悩みました。
これはドキュメントにも記載のある挙動で、テーブル作成とRLSを設定するユーザーと、SELECTするユーザーを分けることで正しくRLSの設定が反映されました。
スーパーユーザ、およびBYPASSRLS属性のあるロールは、テーブルへのアクセス時に、常に行セキュリティシステムを無視します。 テーブルの所有者も通常は行セキュリティを無視しますが、ALTER TABLE ... FORCE ROW LEVEL SECURITYにより、テーブルの所有者も行セキュリティの対象となることができます。 行セキュリティの有効化、無効化、およびポリシーのテーブルへの追加は、常に、テーブルの所有者のみの権限です。
まとめ
Row Level Securityの機能を使って、複数テナントのデータにアクセスさせる設定を試してみました。
USINGに指定する式がbooleanを返せばいいので、パフォーマンスやSQLが複雑になることを許容すればかなり複雑な条件も書けそうです。
using_expression 任意のSQL条件式(戻り値はboolean)です。
が、ここまでやって勤め先のSlackでこの話題を出したところ
同僚から「複数テナントのデータを閲覧させるアプリケーションの仕様って難しいし、ユーザーに"見たいテナント"を選ばせて単一テナントのidをset_configすればいいんじゃない?」というアドバイスをもらいました。
仕様で解決するアプローチですね。そっちの方が賢いし、たいていのマルチテナントなアプリケーションではそれで十分だと思うので、このタイミングで話しておいてよかったです。あっぶねー。