本番だけ SECURITY DEFINER 関数が効かないのは実行ロールのせい
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
SECURITY DEFINER 関数のオーナーはマイグレーションを実行したロールになるため、非特権ロールで流した関数は RLS をバイパスできず、CI では再現しないまま本番でだけ常に0行を返す。
結論
SECURITY DEFINER 関数のオーナーは、そのマイグレーションを実行したロールになります。
非特権ロールでマイグレーションを流すと、関数のオーナーもその非特権ロールになります。すると関数は RLS をバイパスできなくなり、設計上バイパスするつもりで書いた関数が、常に0行を返します。
関数の定義は正しいままです。スキーマ差分にも出ません。変わったのはオーナーだけです。
症状
- CI は全部通る
- staging も動く
- 本番だけ、その関数を使う経路が一律で失敗する
- 例外は出ない。0行が返るだけ
エラーにならないのが厄介なところです。RLS が効いた結果として正しく0行なので、DB は何も文句を言いません。呼び出し側から見ると「該当なし」と区別がつきません。
権限まわりの設定を疑って GRANT を見直しても直りません。GRANT の問題ではなく、オーナーの問題だからです。
原因
3つの事実が重なると起きます。
① SECURITY DEFINER は「関数オーナーの権限」で走る
呼び出したロールではなく、関数を所有しているロールの権限で実行されます。ここまでは仕様どおりで、たいていの解説記事もここは書いています。
② 関数のオーナーは、その CREATE FUNCTION を実行したロールになる
つまりマイグレーションを流したロールが、そのまま関数のオーナーになります。マイグレーションファイルの中に所有者は書かれていないので、実行するまでオーナーは決まりません。
③ テーブルオーナーは RLS をバイパスできる(FORCE を付けていない場合)
ENABLE ROW LEVEL SECURITY だけを付けたテーブルは、オーナーからのアクセスに RLS を適用しません。FORCE ROW LEVEL SECURITY を足すとオーナーも対象になります。
この3つから、こうなります。
テーブルオーナーで流す → 関数オーナー = テーブルオーナー → RLS をバイパスできる → 設計どおり
非特権ロールで流す → 関数オーナー = 非特権ロール → バイパスできない → 常に0行
CI で再現しない理由
CI は superuser で接続してマイグレーションを流すのが普通です。superuser はテーブルオーナーでもあるので、CI では必ず「設計どおり」の側に落ちます。
本番のロール選択でしか顕在化しません。テストを増やしても、CI の接続ロールが superuser のままなら永遠に緑です。
確認する
まず関数のオーナーを引きます。
SELECT p.proname, r.rolname AS owner, r.rolbypassrls
FROM pg_proc p
JOIN pg_roles r ON r.oid = p.proowner
WHERE p.proname = '<関数名>';
次にテーブル側のオーナーを引いて、一致しているかを見ます。
SELECT c.relname, r.rolname AS owner
FROM pg_class c
JOIN pg_roles r ON r.oid = c.relowner
WHERE c.relname = '<テーブル名>';
一致していなければこれが原因です。
実地の確認までやるなら、アプリ用のロールに切り替えて関数を叩きます。
SET ROLE <アプリロール>;
SELECT * FROM <関数名>('<有効な引数>'); -- 1行返れば OK
RESET ROLE;
呼び出し側のコードではなく DB の中で 確認するのが要点です。アプリ経由だと、0行が返っているのか手前で弾かれているのかが混ざります。
直す
オーナーを付け替えます。作り直しは要りません。
ALTER FUNCTION <関数名>(<引数型>) OWNER TO <テーブルオーナーのロール>;
付け替えたら、上の確認をもう一度流します。
再発しない形にする
付け替えだけで終わらせると、次のマイグレーションでまた戻ります。原因は「誰が流したか」なので、直すべきは関数ではなく手順です。
- マイグレーション実行ロールをテーブルオーナーに固定する。 接続情報をロールごとに分け、マイグレーション用の接続文字列を別に持ちます
- その固定をデプロイパイプライン側にも反映する。 手順書だけに書くと、手で流したときに外れます
- オーナーの確認を適用後の検証手順に入れる。 上の SQL を流して一致を見るところまでを「適用完了」の定義にします
CI の緑を強くしようとしても、この失敗は捕まりません。接続ロールが違うという一点が、CI と本番の再現性の壁になっています。壁を消せないなら、本番側に検証を置くしかありません。
同じ「テストは通るのに本番だけ直らない」形の話として、Cloud Run Job はイメージが凍る も書いています。あちらは実行されるコードが古いままで、こちらは実行するロールが違う ── どちらも動いているものが期待と違うのに、エラーが出ません。
よくある質問
Q1テストは全部通るのに本番だけ動かないのはなぜですか?
CI はたいてい superuser で接続してマイグレーションを流します。superuser はテーブルオーナーでもあるので、SECURITY DEFINER 関数のオーナーも superuser になり、設計どおり RLS をバイパスできます。本番だけ非特権ロールで流していると、そこで初めてオーナーが変わります。ロールの違いはスキーマ差分に出ないので、差分比較でも見つかりません。
Q2関数のオーナーはどうやって確認しますか?
pg_proc の proowner を pg_roles と結合して引きます。SELECT p.proname, r.rolname FROM pg_proc p JOIN pg_roles r ON r.oid = p.proowner WHERE p.proname = '<関数名>'; で出ます。テーブル側は pg_class の relowner を同じ要領で引き、両者のオーナーが一致していることを確認します。
Q3オーナーが違っていたら作り直しが必要ですか?
必要ありません。ALTER FUNCTION <関数名>(<引数型>) OWNER TO <テーブルオーナー>; で付け替えられます。ただし付け替えだけで終わらせると次のマイグレーションでまた戻るので、実行ロールを固定するところまでやります。
Q4ENABLE ROW LEVEL SECURITY と FORCE ROW LEVEL SECURITY は何が違いますか?
ENABLE だけの場合、テーブルオーナーは自分のテーブルの RLS をバイパスできます。FORCE を付けるとオーナーも RLS の対象になります。この記事の仕組みは ENABLE のみを前提にしています。FORCE を付けている場合はオーナーであってもバイパスできないので、別の設計が要ります。