Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Vol. 01【PostgreSQL】わたしのクエリ、遅すぎ...?Row Level Securityに潜むLEAKPROOFの罠

技術本部 Bill One Engineering Unit 共通認証基盤チームの古石です。
Bill One 開発 Unit ブログリレー2024の記念すべき第一弾なのですが、少々ニッチな話になります。ごめんなさい。

共通認証基盤では、DBにPostgreSQLを使用しています。
そして、テーブルにRow Level Security(以降RLS)を適用し、テナントIDのチェックを行うことでテナント間のデータ分離を堅牢にしています。

www.postgresql.jp

ところで皆さんは、
RLSを適用している環境で、クエリのパフォーマンスが期待外れに低下した経験はありませんか?
特に LIKE / ILIKE で検索を行う際、INDEXが適切に使われないことがありませんか?

私はあります。先月それで悩まされていました。

検索すれば原因は特定できるような内容だったのですが、まとめているページなどが少なかったので、ここで備忘録として残しておこうと思います。

前提として

PostgreSQLのバージョンは15.4を使用しています。

説明するにあたり、以下のような検証用スキーマ、テーブル、ユーザーを用意します。
テナント数件はかつemailにASCII文字しか入らない想定でemailカラムにGIN(gin_trgm_ops)INDEXを使います。
他にもGiSTB-Treeでも再現するはずです。

-- 検証用にスキーマ作成
CREATE SCHEMA example;
GRANT ALL ON SCHEMA example TO postgres;
SET search_path TO example;

-- 検証用にテーブル作成
CREATE TABLE user_account (
  user_account_id UUID PRIMARY KEY,
  tenant_id VARCHAR(20) NOT NULL,
  email VARCHAR(254) UNIQUE NOT NULL
);

CREATE INDEX idx_user_account_tenant_id_email_btree ON user_account USING btree(tenant_id, email);
CREATE INDEX idx_user_account_email_gin ON user_account USING gin (email gin_trgm_ops);

-- 検証用テーブルにRLSポリシーを設定
ALTER TABLE user_account ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_account_rls_policy_tenant_id ON user_account
  USING (tenant_id = current_setting('app.tenant_id')::VARCHAR);

-- 検証用にユーザーを追加
CREATE USER example_app WITH LOGIN PASSWORD 'example_app';
GRANT USAGE ON SCHEMA example to example_app;
GRANT SELECT ON user_account TO example_app;

データは多めに100万件ぐらい入れておきます。
私のローカルではINSERTに10秒強かかりました。

-- ダミーデータを大量に投入
INSERT INTO user_account (user_account_id, tenant_id, email)
SELECT gen_random_uuid(), 'dummy-tenant', 'user-' || i || '@example.com'
FROM generate_series(1, 1000000) AS s(i);

-- 念の為統計情報更新
VACUUM ANALYSE user_account;

これで準備は完了です。

わたしのクエリ、遅すぎ...?

クエリはemailの部分一致検索を行うとてもシンプルなものとします。
イメージとしては、システム管理者がユーザー検索を行うようなものでしょうか。
条件は部分一致ですが、前方一致や後方一致でも再現します。

SELECT * FROM user_account
WHERE email ILIKE '%3333%';

EXPLAIN ANALYZEで実行計画を見てみましょう。

Bitmap Heap Scan on user_account  (cost=24.77..396.48 rows=100 width=51) (actual time=0.362..3.693 rows=280 loops=1)
  Recheck Cond: ((email)::text ~~* '%3333%'::text)
  Rows Removed by Index Recheck: 3420
  Heap Blocks: exact=1028
  ->  Bitmap Index Scan on idx_user_account_email_gin  (cost=0.00..24.75 rows=100 width=0) (actual time=0.248..0.248 rows=3700 loops=1)
        Index Cond: ((email)::text ~~* '%3333%'::text)
Planning Time: 0.127 ms
Execution Time: 3.720 ms

Execution Time: 3.720 ms 速いですね。
...と思ってしまいますが、実はRLSがバイパスされるpostgresなどの特権ユーザーで実行しています。
検証時に忘れがちなので気をつけましょう。

というわけで先ほど作った検証用のexample_appユーザーで実行してみます。

SELECT set_config('app.tenant_id', 'dummy-tenant', FALSE);

EXPLAIN ANALYZE
SELECT * FROM user_account
WHERE email ILIKE '%3333%';

実行計画を見ると...

-- 検証用example_appユーザーでの実行計画
Gather  (cost=1000.00..19401.67 rows=8000 width=51) (actual time=4.623..283.478 rows=280 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Parallel Seq Scan on user_account  (cost=0.00..17601.67 rows=3333 width=51) (actual time=4.030..276.665 rows=93 loops=3)
        Filter: (((tenant_id)::text = current_setting('app.tenant_id'::text)) AND ((email)::text ~~* '%3333%'::text))
        Rows Removed by Filter: 333240
Planning Time: 0.112 ms
Execution Time: 283.513 ms

Execution Time: 283.513 ms 見事にINDEXが使われなくなっていますね。
Parallel Seq Scanになっていて、本番環境であれば負荷的にも危険です。

そういえば特権ユーザーで実行した際にtenant_idを条件に入れていなかったので、tenant_idを入れて再度試してみましたが、INDEXは適切に使われました。

EXPLAIN ANALYZE
SELECT * FROM user_account
WHERE
    tenant_id = current_setting('app.tenant_id')
    and email ILIKE '%3333%';
-- 特権ユーザーでの実行計画
Bitmap Heap Scan on user_account  (cost=24.77..396.98 rows=100 width=51) (actual time=0.378..3.669 rows=280 loops=1)
  Recheck Cond: ((email)::text ~~* '%3333%'::text)
  Rows Removed by Index Recheck: 3420
  Filter: ((tenant_id)::text = current_setting('app.tenant_id'::text))
  Heap Blocks: exact=1028
  ->  Bitmap Index Scan on idx_user_account_email_gin  (cost=0.00..24.75 rows=100 width=0) (actual time=0.262..0.262 rows=3700 loops=1)
        Index Cond: ((email)::text ~~* '%3333%'::text)
Planning Time: 0.151 ms
Execution Time: 3.697 ms

また、pg_hint_planを入れてヒント句でINDEXを明示したうえで検証用exmaple_appユーザーでクエリを実行しても、実行計画は変わりませんでした。

-- これは効果がない
/*+
  BitmapScan(user_account idx_user_account_email_gin)
*/

RLSの影響が疑わしい

特権ユーザーと検証用ユーザーのクエリの条件の違いはRLSの適用有無ぐらい、さらにヒント句が効かないとなると、これはもうRLSが影響しているだろう...
と考え検索していたら同じ事象の人がいました。

stackoverflow.com

stackoverflow.com

どうやらLIKE / ILIKE検索で用意したINDEXが使用されない理由は、LIKE / ILIKEで使用される演算子がLEAKPROOFではないからだそうです。

LEAKPROOFとは

公式ドキュメントのCREATE FUNCTIONの項目に詳しく記載がありました。

LEAKPROOFは、関数が副作用を持たないことを示します。 その引数に関する情報を戻り値以外で漏らしません。 例えば、一部の引数値に対してのみエラーメッセージを返す関数や何らかのエラーメッセージの中に引数の値を含める関数は漏洩防止(leakproof)とはいえません。 これはsecurity_barrierオプション付きで作成されたビュー、あるいは行単位セキュリティが有効にされたテーブルに対して、システムが問い合わせを実行する方法に影響します。 データが偶然に露見することを防ぐため、システムは、漏洩防止でない関数を含む問い合わせのユーザが提供した条件より前に、セキュリティポリシーおよびセキュリティバリアビューの条件を強制します。 漏洩防止であるとされた関数および演算子は信頼できると見なされ、セキュリティポリシーおよびセキュリティバリアビューによる条件より先に実行されることがあります。

引用元: https://www.postgresql.jp/docs/15/sql-createfunction.html

ざっくりと要約すると、演算子のLEAKPROOFtrueの場合はRLSのフィルタリングより先に実行できるが、falseの場合はRLSのフィルタリングが先に適用される、ということのようです。 何らかのエラーメッセージの中に引数の値を含める関数という部分は、サイドチャネル攻撃などのリスクを考慮しているようです。

ではLIKE/ILIKELEAKPROOFfalseなのか確認してみましょう。

-- 実行計画に出てくる演算子からコードを特定(~~がLIKE、~~*がILIKE)
SELECT oprname,  oprcode
FROM pg_operator
WHERE oprname in ('~~', '~~*')
ORDER BY oprname, oprcode;
oprname oprcode
~~ textlike
~~ namelike
~~ bpcharlike
~~ bytealike
~~* texticlike
~~* nameiclike
~~* bpchariclike
-- 今回はtextの部分一致なのでtextlike, texticlikeで調べる
SELECT proname, proleakproof FROM pg_proc
WHERE proname IN ('textlike', 'texticlike');
proname proleakproof
textlike false
texticlike false

結果、確かにproleakprooffalseになっていました。
ということは、RLSのフィルタリングが先に実行されてしまい、用意したINDEXが使用されない可能性が高そうです。

ちなみに、pg_procを眺めてLEAKPROOFなオペレータを調べたところ、概ね= / != / < / <= / > / >= のような単純な演算子が LEAKPROOFに設定されているようでした。
それ以外は LEAKPROOFではないというわけです。

PostgreSQL標準のLEAKPROOFの選定方針については詳細を調べきれなかったのですが、直近のPostgreSQLメーリングリストで、関数インデックスとLEAKPROOFに関する相談があり、その中で言及されていました。

www.postgresql.org

内容としては、upperlowertextlikeといった文字列処理系の関数を標準でLEAKPROOFに指定できないか、という議論でした。
しかし、これらの関数は入力データによってエラーメッセージが変わる可能性があり、その情報が攻撃者に利用されるリスクを懸念していました。(上述した公式ドキュメントのLEAKPROOFの説明と一致しています。)
PostgreSQLの開発者としてはわずかなリスクでも考慮して、標準でLEAKPROOFに指定することには慎重であるべきという方針のようです。

LEAKPROOFではないことが原因だった

では、本当にLEAKPROOFが影響しているか確認しましょう。
Stack Overflowのスレッドによると、LEAKPROOF属性はALTER FUNCTIONで更新できるようなので、trueにした上で再度実行計画を取得してみます。

ALTER FUNCTION textlike LEAKPROOF;
ALTER FUNCTION texticlike LEAKPROOF;

-- 戻す時は
-- ALTER FUNCTION textlike NOT LEAKPROOF;
-- ALTER FUNCTION texticlike NOT LEAKPROOF;

そして検証用のexample_appユーザーでクエリを実行します。

SELECT set_config('app.tenant_id', 'dummy-tenant', FALSE);

SELECT * FROM user_account
WHERE email ILIKE '%3333%';
-- texticlikeをLEAKPROOFにした状態で、検証用example_appユーザーでの実行計画
Bitmap Heap Scan on user_account  (cost=24.77..396.98 rows=100 width=51) (actual time=0.387..3.089 rows=280 loops=1)
  Recheck Cond: ((email)::text ~~* '%3333%'::text)
  Rows Removed by Index Recheck: 3420
  Filter: ((tenant_id)::text = current_setting('app.tenant_id'::text))
  Heap Blocks: exact=1028
  ->  Bitmap Index Scan on idx_user_account_email_gin  (cost=0.00..24.75 rows=100 width=0) (actual time=0.287..0.287 rows=3700 loops=1)
        Index Cond: ((email)::text ~~* '%3333%'::text)
Planning Time: 0.181 ms
Execution Time: 3.144 ms

Execution Time: 3.144 ms ...改善しました。INDEXが使われなかった原因はLEAKPROOFで間違いないようです。

ちなみに、部分一致ではts_vector型でts_queryを条件に使うこともあると思いますが、演算子的にはLEAKPROOFではないため、LEAKPROOFにしない限りは同様にパフォーマンス劣化の懸念があるかと思われます。

対策はどうすればいい?

先ほど挙げたようにALTER FUNCTIONLEAKPROOFにすれば、適切なINDEXが使われるようになりパフォーマンスは改善します。
しかし、サイドチャネル攻撃などのリスクを許容することになるため、組織や開発チームとして正しく検討する必要があるかと思われます。

私の場合は、今回のようなクエリで実現している検索機能が必要かどうかを見直し、機能自体を削除しました。

他にも(根本的な解決ではないので要注意ですが)以下のような対策も考えられます。

  • RLSが適用されないMaterializedViewを作る
  • SECURITY DEFINER を適用したFunctionを作成し、特権ユーザーで実行する
  • テーブルにRLSポリシーを適用しない
  • テナントのデータ分離の仕組みを考え直す

要件とコストが見合うのであれば外部検索エンジンを使うという選択もあるかと思います。

まとめ

  • LIKE / ILIKEの内部的な処理がLEAKPROOFではないため、RLSのフィルタが優先的に適用され、狙ったINDEXが効かないことがある。
  • LEAKPROOFではない処理は、エラーメッセージの中に入力情報が入る可能性がある。
  • ALTER FUNCTIONLEAKPROOFにすれば解消するが、根本的な対策にはならない。他の対策には一工夫必要。

スッキリと解決するには至らなかったのですが、本記事が同じ事象で悩める方の足がかりにでもなれば幸いです。
最後まで読んでいただきありがとうございました。


次回、Bill One 開発 Unit ブログリレー2024 vol.2組織として、定期的に品質向上と改善に向けた取り組みをどのように推進しているかです。
ブログリレーの最新の投稿についてはXアカウント @SansanTech でお知らせしますので、フォローよろしくお願い致します。

© Sansan, Inc.