PostgreSQLのRow Level Securityで複数テナントのデータにアクセスできる設定

マルチテナントを実現するときに便利な機能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により、テーブルの所有者も行セキュリティの対象となることができます。 行セキュリティの有効化、無効化、およびポリシーのテーブルへの追加は、常に、テーブルの所有者のみの権限です。

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

まとめ

Row Level Securityの機能を使って、複数テナントのデータにアクセスさせる設定を試してみました。
USINGに指定する式がbooleanを返せばいいので、パフォーマンスやSQLが複雑になることを許容すればかなり複雑な条件も書けそうです。

using_expression 任意のSQL条件式(戻り値はboolean)です。

CREATE POLICY

が、ここまでやって勤め先のSlackでこの話題を出したところ

同僚から「複数テナントのデータを閲覧させるアプリケーションの仕様って難しいし、ユーザーに"見たいテナント"を選ばせて単一テナントのidをset_configすればいいんじゃない?」というアドバイスをもらいました。

仕様で解決するアプローチですね。そっちの方が賢いし、たいていのマルチテナントなアプリケーションではそれで十分だと思うので、このタイミングで話しておいてよかったです。あっぶねー。