どうも、PrAha Inc. CEOです。特技はエンジニアのモノマネです。
今回はマルチテナント型のサービスを設計する時にお世話になるPostgreSQLのRow Level Securityにポリシーを設定し忘れと何が起きるのか共有します。
Row Level Securityとは
その名の通り「行単位」でアクセス権限を分けられるため、「この行は、特定のテナントidを持った人だけ取得できる」みたいな細かなアクセス権限ができる。
何が起きたのか
PostgreSQLのRLS(Row Level Security)を設定した結果、何をINSERTしようとしても「new row violates row-level security policy for table」と怒られるようになった
結論
- PostgreSQLのRLSはデフォルト拒否設定(ポリシーを指定し忘れると、アクセスを拒否される)
- SELECT権限は付与したが、INSERT権限を付与し忘れていた
- 要はポカミス
If row-level security is enabled for a table, but no applicable policies exist, a "default deny" policy is assumed, so that no rows will be visible or updatable.
何をしたかったのか
PostgreSQLにはRLS(Row Level Security)が存在する。
RLSを設定することで、レコードごとにCRUDルールを設定できる。
例えばマルチテナント型のサービスを設計すると
select * from posts where team_id = ''
;
みたいなwhere文を使って、自分自身のテナントに関連する情報に絞り込むことが多い。
しかし何らかのミスでwhere文を入れ忘れると、どうなるか。
全く関係ないテナントの情報が取得できてしまう。
重大事故だ。謝罪行脚の旅が始まる。
こうならないために、マルチテナント型のサービスでは様々な対策を講じる
実装段階の対策
- テストを書く
- PRレビューを行う
- 開発環境で確認する
しかし実装段階の対策にはヒューマンエラーが入り込む余地が大きい。
「今の体制、メンバーならそんなミスしないよ」
と思っていたとしても、メンバーが経験の浅いエンジニアに総入れ替えされる可能性はある。「早くデリバリーしろ!テストなんて書いてんじゃねぇ!」と強い圧をかけられて屈してしまう可能性もある。テストを書く習慣や、PRレビューを行う習慣が、自分の手元を離れた後も生涯維持される保証はない。
こうならないため、普通のサービスでは設計段階の対策を講じる。
設計段階の対策
- テナントごとにDBを分ける(ただしマイグレーション時のパフォーマンス問題がつきまとう)
- Railsであればapartmentみたいなgemを使う
- PostgreSQLであれば、RLSを設定して、テナントidを指定しないとエラーが発生するようにする
今回は最後のPostgreSQLのRLSでテナントidの指定忘れを対策しようと考えた
どんなRLSを設定したのか
CREATE POLICY xxx ON table_name FOR SELECT
USING (team_id = current_setting('team.id'));
どんなINSERTクエリを書いたのか
SET team.id = 'hogehoge'; // テナントIDを指定
INSERT INTO xxx VALUES (省略);
想定していた結果
OK! // だってテナントIDちゃんと指定したもんね〜
実際の結果
new row violates row-level security policy for table // あれ!?
原因は明白ですね。
FOR SELECTだけ定義したので、SELECT以外に関してはデフォルト拒否、つまり何があってもアクセスを弾くようになってしまったんですね。
解決策
その1(WITH CHECK)
CREATE POLICY xxx ON knowledge_code_users FOR INSERT
WITH CHECK (team_id = current_setting('team.id'));
ちゃんとINSERT用のルールも定義してあげよう。シンプルな解決策。
その2:おまけ(FOR ALL)
CREATE POLICY xxx ON table_name FOR ALL
USING (team_id = current_setting('team.id'));
なぜこれが動くのか
-
Existing table rows are checked against the expression specified in USING
(既存レコードはUSINGでチェックする) -
while new rows that would be created via INSERT or UPDATE are checked against the expression specified in WITH CHECK.
(新規レコードはWITH CHECKでチェックする)
RLSのポリシーチェックには2種類あり、既存/新規で、適用されるルールが異なります。
なので
- SELECTに対するルールを書くときはUSING
- INSERTに対するルールを書くときはWITH CHECK
と使い分けるワケですが、FOR ALLを選択した場合、ドキュメントによると以下ルールが適用されます:
For policies that can have both USING and WITH CHECK expressions (ALL and UPDATE), if no WITH CHECK expression is defined, then the USING expression will be used both to determine which rows are visible and which new rows will be allowed to be added
(ALLに対するポリシーを設定した場合、かつWITH CHECKがない場合、既存レコードのチェックも新規レコードのチェックも、USINGを使うよ)
なのでFOR ALLを指定すれば、WITH CHECKをあえて定義しなくてもUSINGだけで済みます。
ユースケース次第ですが、基本的に他テナントのレコードはCRUD全て禁止したいのであれば、FOR ALLで縛ってしまうのもアリです
まとめ
- PostgreSQLのRLSはデフォルト拒否