Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

本番だけ SECURITY DEFINER 関数が効かないのは実行ロールのせい

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

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 を付けている場合はオーナーであってもバイパスできないので、別の設計が要ります。