Row Level Security による素朴なマルチテナント実装

引き続き(しばらくは) SaaS 開発をやっていくことになったので、フロントエンドエンジニアではあるが SaaS バックエンドについても知見を持っておこうとしている。前々からの懸念・課題としてマルチテナントの DB をどう安全に実現するかというものがあったため、それを調べることにした。

この記事では、あくまで具体的な動く例を元に解説を加えているだけなので、軽く知りたい方や実例・ストーリーを求める方は記事最後の参照記事を見るだけで十分かもしれない。

ソースはこちらから

背景

SaaS アプリケーションでは、金銭的なコストやマイグレーションのコスト削減のために DB もテナントで共用する構成(以下、マルチテナント構成)が求められる。 これは一つのリソースユニットですべてのリクエストを捌くという意味でなく、テナント専用の割り当てがないリソースユニットでテナントを選ばず捌くということ。スケールアウトでユニット(=サーバー)は増える。

テナント毎にアプリケーションサーバーと DB サーバーを用意すると金銭コスト・メンテナンスコストが高いがマルチテナント要因の情報漏洩は起きない[1]
アプリケーションサーバーはテナント間で共有し DB だけを(論理的・物理的に)分ける形態だと、コストは少し浮く可能性があるものの、まだ十分とは言えない。移行中のステップや旧来のシステムのクラウド化の形態としてあるかもしれない。
マルチテナント構成では、(繰り返しになるが) DB もアプリケーションサーバーも共有する。事業のスケール後でコストを最小化できる一方、うっかりするとすぐにテナント外に漏洩してしまう。

意図せず最初に非マルチテナント構成で作ってしまい、さらに、例えばテナント毎に別々に(機能・修正による)アップデートが行われる運用などで、プロダクションバージョンがテナント毎に違う状態になると、メンテナンス上とてもつらい状況になるし、 SaaS 形態の恩恵を受けられない。(私は何も見ていない)

最小限の実装

DB 層のマルチテナント構成にするとして、一番真面目な方法は、 tenant_id などユニーク値列を必要なテーブルすべてに追加し、 DB へのクエリ・コマンドのやり取りの際に 忘れず tenant_id を付与して実行することだろう。

1
2
3
4
5
6
7
CREATE TABLE employee
(
tenant_id varchar(16) NOT NULL,
employee_id varchar(16) NOT NULL,
first_name varchar(16) NOT NULL,
last_name varchar(16) NOT NULL,
);
1
2
3
4
5
6
val statement1 = connection.prepareStatement(
"SELECT first_name, last_name FROM employee WHERE tenant_id = ?")
statement1.setString(1, tenantId)
val resultSet1 = statement.executeQuery()

val statement2 = connection.prepareStatement("INSERT INTO employees(...) VALUES (...)")

マルチテナント構成が求められるような企業向け SaaS アプリケーションでは、他テナントへの漏洩は信用と評判という面で失注や解約を起こし、運営開発企業を潰しかねない[2]
しかし、人間はミスをする生き物なので、コードのどこかに漏洩につながるクエリ・コマンドが紛れ込むだろう。これを防ぐために、網羅性のある莫大な回数のテストを実行したり、徹底的にコードレビューし指摘し尽くそうとしたりするなどの方法で防ごうとするかもしれないが、こちらもつらいし抜け漏れが出てくる可能性は消せない。

解決方法

ネットサーフィンにより調査した結果、以下の方法が発見できた。最後のもの以外を手元で動かしてはいないので、これから示す疑似コードをそのまま実行しても動く保証はない。

  • フレームワークや ORM の利用
  • テナント毎のデータベースやテーブル
  • テナント x テーブル のビュー
  • Row Level Security の活用
KVS で主キーまたは複合キーに tenant_id を指定すればミドルウェアだけで似たことはできるが、企業向け SaaS システム全体の要求をすべて満たすミドルウェアか甚だ怪しい。

フレームワークや ORM の利用

フレームワークが良しなにテナントでフィルタリングをしてくれる方法。 OSS として開発されているものも多いようで、それを使えばおそらく一番お手軽に実現できるだろう。
実現方法は様々あるだろう。後述する方法を採用するものを始めとし、 ORM ・フレームワーク・ライブラリのレベルで面倒な部分を隠蔽してくれるものもあると思う。

現状はこの方法を最初に検討するとよいのではなかろうか。ただし、実装・実現方法はしっかり確認する必要がある[3]

テナント毎のデータベースやテーブル

これはテナント毎のデータベースやテーブルを作り、データベース名・テーブル名を可変にしたクエリ・コマンドでアクセスする方法だ。実際にはフレームワーク等で自動的に作成されると思うが、手作業でやるイメージはそれぞれ以下のようになる。

データベース

1
2
3
4
5
6
7
CREATE DATABASE contoso;
USE contoso;
CREATE TABLE employee(...);

CREATE DATABASE example_com;
USE example_com;
CREATE TABLE employee(...);
1
2
3
4
5
6
7
val statement1 = connection.createStatement()
statement1.execute("USE $tenantId")

val statement2 = connection.createStatement()
val resultSet2 = statement2.executeQuery("SELECT first_name, last_name FROM employee")

val statement3 = connection.prepareStatement("INSERT INTO employees(...) VALUES (...)")

テーブル

1
2
CREATE TABLE contoso__employee(...);
CREATE TABLE example_com__employee(...);
1
2
3
4
5
6
7
val statement1 = connection.createStatement()
val resultSet1 = statement1.executeQuery(
// テーブル名を変えられるプリペアードステートメントは NG なためこうなる
"SELECT first_name, last_name FROM ${tenantId}__employee")

val statement2 = connection.prepareStatement(
"INSERT INTO ${tenantId}__employee(...) VALUES (...)")

テナント数が増えてくると比例して DB サーバーのマイグレーションに時間がかかってつらいらしい。
加えて、複数のテナントからデータを取得するようなクエリや、テナント間で共用するテーブルと JOIN があるなどすると(SQL アンチパターンにおける)ポリモーフィック関連となり、やりようはあるがかなり面倒になる。

参照記事を読む限り、 1000 テナントを超えるともう厳しくなってくるのではないかと思う。ちょうどその頃は、ビジネス的にもお客さんから引き合いがありプロダクトが伸びていてテナント数が急増するフェーズで、問題が露呈し始めて深刻になるまでの時間は短いだろう。

テナント x テーブル のビュー

テナントを跨いでオリジナルを持つ共用テーブルと、それをテナント毎に参照するビューを作り、ビュー名を可変にしたクエリ・コマンドでアクセスする方法。 SELECT と、一部の UPDATE と DELETE は前の節と同じようなクエリ・コマンドになる。
その残りと INSERT はビューを通さずオリジナルのテーブルに更新をかける。

1
2
3
4
5
6
CREATE TABLE employee(...);

CREATE VIEW contoso__employee AS
SELECT * FROM employee AS e WHERE e.tenant_id == "contoso";
CREATE VIEW example_com__employee AS
SELECT * FROM employee AS e WHERE e.tenant_id == "example_com";
1
2
3
4
5
6
val statement1 = connection.createStatement()
val resultSet1 = statement1.executeQuery(
// テーブル名を変えられるプリペアードステートメントは NG なためこうなる
"SELECT first_name, last_name FROM ${tenantId}__employee")

val statement2 = connection.prepareStatement("INSERT INTO employees(...) VALUES (...)")

テナント毎にテーブルを用意するよりは良さそうだが、コマンドの内容によってテーブルを使ったりビューを使ったりを切り替える必要があり、また、オリジナルのテーブルへとクエリでアクセスする事故が発生しうるため、安心できない。

Row Level Security の活用

RDBMS によっては Row Level Security という仕組み(あるいは相当するもの)が実装されている。軽く検索した結果、有名どころでは PostgreSQL 、 SQL Server 、 MariaDB 、 Oracle がこれに該当するらしい。
Row Level Security を簡潔に説明すれば、 POLICY という記述により行ごとに権限を制御できる、という仕組み。

Row Level Security を使って実装する

多くは参照記事に基づいている。少し改良した部分もある。

戦略

まず、マルチテナントの必要性があるテーブルには tenant_id 列を主キー等としてつけて、 Row Level Security を有効化する。
次に、アプリケーションサーバーがアクセスするためのロールを作成し、これに対して実行時パラメータ(必須)を利用してアクセス可否を確認する POLICY を作る。

admin やメンテ・バッチ用のロールを作れば、 Row Level Security が有効なテーブルでもテナントを跨いで取得・操作できる。

実装

PostgreSQL のサーバーを立てる。ここでは Docker を使う。latest のタグが付いたコンテナなら動くだろう。

$ docker run --rm --name some-postgres -p 5432:5432 \
-e POSTGRES_USER=admin -e POSTGRES_PASSWORD=passw0rd -d postgres

app というスキーマ以下にアプリケーションデータを格納することとし、アプリケーションサーバーからアクセスするときは app_server というロールを利用することにする。適宜スキーマ・テーブル等に権限を与える。手元で試すときに面倒ならデフォルトで存在する public スキーマを使ってもよいかもしれない。
app.tenant_idapp スキーマ内での(一時的な)設定を変数として表現している。これは自作設定のため、 RDBMS が保持する設定と干渉せず、動作に副作用を起こすことはないだろう(と思う)。
また、 app.tenant_id を設定せずに読み書きしようとすると ERROR: unrecognized configuration parameter "app.tenant_id" という形で RDBMS がエラーを出すようになる。

全ての詳細な手順はサンプルコードを見ていただくとして、ここでは重要な部分を抜き出す。デフォルトで存在する admin アカウントでデータベース設定用のスクリプト ‘init.sql’ を実行すると、これらの設定の元にテーブルやスキーマが作成される。
ALTER TABLE ??? ENABLE ROW LEVEL SECURITY とすることで、指定したテーブルに対し Row Level Security が有効になる。その下で POLICY を作っている。 current_setting は、指定した設定を取得できる関数。

1
2
3
4
ALTER TABLE employee ENABLE ROW LEVEL SECURITY;

CREATE POLICY employee_rls ON employee TO app_server
USING (tenant_id = current_setting('app.tenant_id'));

‘init.sql’ で作成された app_server ロールで ‘data.sql’ を実行すると、データが作成される[4]

テナント毎にデータを取得すべきテーブルへのアクセスの前には、app.tenant_idset_config 関数にて設定するクエリを実行する[5]。 その直後に、テーブルにアクセスして処理を行う。

1
2
3
4
5
6
val statement1 = connection.prepareStatement("SELECT set_config('app.tenant_id', ?, false)")
statement1.setString(1, tenantId)
statement1.execute()

val statement2 = connection.createStatement()
val resultSet = statement2.executeQuery("SELECT first_name, last_name FROM app.employee")

結果

データベースの設定とデータ投入が終わっている状態で ‘main.kt’ を実行すると、以下のように出力される。

1
2
3
4
5
6
7
8
9
Employee(id=1, firstName=Alice, lastName=Smith, email=smith@foo.example.com, birthday=1987-06-05)
Employee(id=2, firstName=Bob, lastName=Johnson, email=bob@foo.example.com, birthday=1943-02-10)
Employee(id=1, firstName=Charlie, lastName=Williams, email=williams@bar.example.com, birthday=2000-02-20)
Employee(id=2, firstName=Dave, lastName=Brown, email=brown@bar.example.com, birthday=1999-09-09)
News(id=1, announcedBy=Foo Ltd., text=Happy holidays!)
News(id=2, announcedBy=Bar Corporation, text=We made a new phone.)
Exception in thread "main" org.postgresql.util.PSQLException: ERROR: unrecognized configuration parameter "app.tenant_id"
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2578)
...

1,2 行目が “foo” テナント、 3,4 行名が “bar” テナントのデータである。どちらも WHERE tenant_id = ? によるフィルタを使っていないが、自分のテナントのデータしか出力されていないのが確認できる。
5,6 行目に出ているテナント制限がないテーブルからは、テナントにかかわらずデータが出力されている。

7 行目は、テナントの指定を忘れてクエリを実行して例外が出たところ。(データが漏洩しないという点で)安全側に倒れている。
例外を出させるべきか、空の結果を出すべきかは議論が分かれると思う。個人的には、前者のほうがミスが判明しやすく、バグ管理の面でよいと思っている。

注意事項

ここで紹介した手順やサンプル実装を元にこの方法を実戦投入する前に、まだ検討すべきことがある。

速度

Row Level Security と POLICY 有りでは、実運用において、インデックス等が適切に効いていても、普段の 1.4 倍の時間がかかる(つまり遅い)という計測結果が提示されている。

ミドルウェアの進歩に期待する手もあるが、速度要件がクリティカルな場合は採用できない方法かもしれない。

コネクションを閉じない場合

このサンプルは、リクエストごとに毎回 DB へのコネクションを開き、都度閉じる実装になっている。一方、場合によってはコネクションを開きっぱなしにし、同じコネクションで多数のリクエストを捌きたいかもしれない。
この状況では、このサンプルは以下の手順を踏むとデータが漏れてしまう。

ステップ app.tenant_id
0 (DB コネクションを開く)
1 /hoge への “foo” テナントのリクエストを受けた
2 set_config 部分を “foo” で実行する foo
3 SQL 問合せを実行する foo
4 “foo” テナントの結果が取得され表示した foo
5 (そのまま次のリクエストを待つ) foo
6 /fuga への “bar” テナントのリクエストを受けた foo
7 set_config 部分を “bar” で実行し忘れる foo
8 SQL 問合せを実行する foo
9 “bar” テナントでなく “foo” テナントの結果が間違って取得され表示した foo
10 データ漏洩→信用失墜

これの対策の一つとして、set_config の第三引数を true とするとトランザクションの終わりに設定が戻る(=テナント指定が消える)ことを利用し、各々の処理のトランザクションを(SELECT についても)厳密にやるかラッパー・アノテーションの手を借りる方法が考えられる。(JDBC の場合は autoCommit = false が必要)

まとめ

  • SaaS アプリケーションでの DB のマルチテナント化の必要性と漏洩ミスについて説明した。
  • DB マルチテナント化の方法をいくつか紹介した。
  • Row Level Security による DB のマルチテナント化の例を提示した。

参照記事


  1. そもそもマルチテナント化してないので当然その問題は起こりようがない。 ↩︎

  2. そもそも自社・グループ会社向けシステム以外でこれをやっているところはあるのだろうか? ↩︎

  3. apartment という Ruby on Rails の gem のように、大規模だと問題が起きることが知られているものがある。 ↩︎

  4. これは運用時にデータが追加されるのを再現している ↩︎

  5. (クエリとして使う) SELECT で副作用が起きているのが何とも言えないが… ↩︎