Rebounder Tech Blog

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

audit_logのhash chainがFORCE ROW LEVEL SECURITYで学校ごとに分岐した

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

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

結論

audit_logのhash chainトリガ自身が実行するSELECTも、INSERT元のテナントcontextのままFORCE ROW LEVEL SECURITYの対象になるため、1本のつもりの鎖が学校ごとに静かに分岐する。

結論

改ざん検知用のhash chainトリガが「直前行」をSELECTするとき、そのSELECT自身もFORCE ROW LEVEL SECURITYの対象になる。 audit_logは書き込み元のroleに関わらず全体で1本の鎖になるはずだったが、トリガ関数本体のSELECTがINSERTを発行したテナントcontextのまま実行されるため、school_adminからのINSERTは「全体の直前行」ではなく「自校の直前行」しか見えず、鎖が学校ごとに静かに分岐していた。改ざんは一切起きていないのに、検証関数は分岐を改ざんと区別できずbrokenを返す状態だった。

症状

audit_logはcross-tenantのaudit証跡テーブルで、テーブル所有者による改ざんも防ぐためFORCE ROW LEVEL SECURITYが掛かっている。

-- 0001_enable_rls.sql(2026-05-29導入時点)
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;

読取policyは「自校 or system_admin」のみ。

-- 0002_rls_policies.sql(同時点)
DROP POLICY IF EXISTS audit_log_tenant_read ON audit_log;
CREATE POLICY audit_log_tenant_read ON audit_log FOR SELECT
  USING (
    school_id = current_setting('app.current_school_id', true)::uuid
    OR current_setting('app.current_user_role', true) = 'system_admin'
  );

ここに、INSERT時に直前行のrow_hashをprev_hashとして繋ぐトリガが乗る。

-- 0003_audit_trigger.sql(同時点、audit_log_set_hash())
  -- 直前行の row_hash を取得 (なければ null = 先頭行)
  SELECT row_hash INTO v_prev_hash
    FROM audit_log
   ORDER BY occurred_at DESC, id DESC
   LIMIT 1;

school_adminのcontextでA校がINSERTすると、このSELECTはaudit_log_tenant_readpolicyに従い「A校の行」しか見えない。次にB校のschool_adminがINSERTすると、同じSELECTは「B校の行」しか見えない。結果、鎖の先頭からB校の初行が「A校の直前行」ではなく「nullまたはB校自身の過去行」に繋がり、1本のつもりの鎖が学校数だけ枝分かれする。audit_log_verify_chain()(全体1本前提の検証関数)は、改ざんが一度も起きていない状態でもbrokenを返していた。

原因

FORCE ROW LEVEL SECURITYは「テーブル所有者にもpolicyを強制する」設定で、所有者がRLSをバイパスして改ざんする経路を塞ぐためのものだった。この設定自体は正しい。問題は、トリガ関数の本体が実行するSELECTも「関数を呼び出したINSERTのテナントcontext」のまま評価されるという点が見落とされていたことだった。トリガはINSERT1行に対して「テーブル全体の直前行」を引くつもりだったが、RLSの視点では「school_adminというroleがSELECT ... FROM audit_logを実行した」という操作そのものであり、policyはそのroleのセッション変数(app.current_school_id)に基づいて行を絞る。トリガが暗黙に「自分は特権的な内部処理だ」と仮定していた部分と、実際には呼び出し元のpolicyがそのまま効くという実装の間にギャップがあった。

直し方

トリガ関数の本体の中で、直前行を引く間だけapp.current_user_roleをsystem_adminに切り替える方式にした。

-- 0039_audit_log_global_chain.sql(修正後の audit_log_set_hash())
  -- 全体の直前行を、引く間だけ system_admin として読む(RLS で自校に絞られない)。必ず元に戻す。
  v_role := COALESCE(current_setting('app.current_user_role', true), '');
  BEGIN
    PERFORM set_config('app.current_user_role', 'system_admin', true);
    SELECT a.row_hash, a.occurred_at INTO v_prev_hash, v_prev_at
      FROM public.audit_log a
     ORDER BY a.occurred_at DESC, a.id DESC
     LIMIT 1;
    PERFORM set_config('app.current_user_role', v_role, true);
  EXCEPTION WHEN OTHERS THEN
    PERFORM set_config('app.current_user_role', v_role, true);
    RAISE;
  END;

関数定義にSET app.current_user_role = ...という関数レベルのSET句を付ける案は最初に検討されたが、レビューで「PostgreSQL 15以降、カスタムGUCを関数SET句に指定できるのはsuperuserだけ(permission denied to set parameter)」という指摘が入り、本番のmigratorロールはsuperuserではないため通らなかった。そのため関数本体でset_configにより一時的に切り替え、SELECTの直後、例外時も含めて必ず元のroleへ戻す実装に変更された。関数を抜けた時点で呼び出し元のroleに戻っているため、INSERT自体のWITH CHECK(actor詐称防止)はBEFOREトリガの後、呼び出し元の本来のroleで評価される。他人のuser_idをactor_user_idに詐称したINSERTは従来どおり拒否されることがテストで確認されている。

SECURITY DEFINERにする選択肢も検討されたが採らなかった。所有者(migrator)のBYPASSRLSに暗黙に依存することになり、SECURITY DEFINER関数の許可リストを増やすことにもなるため、既存のresolve_magic_link等のmigrator不変条件を崩さない方を優先した。

// audit-log-global-chain.test.ts:148-153
it("トリガ関数は SECURITY DEFINER でも関数 SET 句でもない(本体の set_config で一時的に切り替える)", async () => {
  const [fn] = await raw<{ prosecdef: boolean; proconfig: string[] | null }[]>`
    SELECT prosecdef, proconfig FROM pg_proc WHERE proname = 'audit_log_set_hash'
  `;
  expect(fn.prosecdef).toBe(false);
  expect((fn.proconfig ?? []).some((c) => c.startsWith("app."))).toBe(false);
});

分岐の確認は2校を交互・並行の両方で書かせるテストで取っている。

// audit-log-global-chain.test.ts:73-86
it("2 校の school_admin が交互に書いても chain は分岐しない(A→B→A で broken 0)", async () => {
  const a1 = await insertAs(db, ctxA(), fx.schoolA, fx.userA, 1);
  const b1 = await insertAs(db, ctxB(), fx.schoolB, fx.userB, 2);
  await insertAs(db, ctxA(), fx.schoolA, fx.userA, 3);
  // ...
  // B 校の初行は(B からは見えない)A 校の行に繋がる
  expect(rows[1]?.prev_hash).toBe(rows[0]?.row_hash);
  expect(rows[2]?.prev_hash).toBe(rows[1]?.row_hash);
  expect(await brokenCount()).toBe(0);
});

並行INSERTで鎖順がずれないよう、advisory lockを取得した後にoccurred_atをGREATEST(clock_timestamp(), 直前行+1µs)で確定する処理も同時に入れた。advisory lock待ちの間にコミットされた行が後から見えるため、ロック取得前にoccurred_atを決めると「ロック取得順 ≠ occurred_at順」がずれて並行時に誤検知し得たためだ。既存行は一切触らず、切替点としてdiff.event = hash_chain_global_cutoverという1行だけを書き加えている。本番適用はskill apply-migrationの人間ゲート待ちで、本稿執筆時点ではまだ適用されていない。

再発防止

このバグはRLSのテストスイート自体が「tenant isolation」「RLS越しにhash chainが崩れないか」を別の軸として見ていたために素通りした。FORCE ROW LEVEL SECURITYは「所有者がバイパスできない」ことを保証する設定であり、「トリガ内部のSELECTが特権操作として扱われる」ことは保証していない。この2つは字面が似ているため、テーブルへのRLS適用レビューの観点からは「正しく掛かっている」としか見えず、トリガ関数の中身まで遡って「この関数が実行するSELECTは誰のroleで評価されるか」を確認する視点が無いと見落とす。修正後のテストは、2校の交互書き込み・2校の並行30行書き込み・actor詐称拒否・role非リーク・occurred_at上書き・SECURITY DEFINER不使用の6パターンを固定しており、将来同じ関数に手を入れてSELECTのcontext切り替えを外した場合、これらのテストが落ちる形になっている。

よくある質問

Q1なぜFORCE ROW LEVEL SECURITYを付けたトリガ自身のSELECTまでRLSの対象になるのですか?

FORCE ROW LEVEL SECURITYはテーブル所有者にもpolicyを強制し、所有者によるRLSバイパスを防ぐための設定。その結果、トリガ関数本体が実行するSELECTも呼び出し元のapp.current_user_role等に基づくpolicyの対象になる。トリガが「直前の1行」を取るつもりでも、呼び出し元がschool_adminなら、policyがその1行を自校分だけに絞ってしまう。

Q2SECURITY DEFINERにすれば直せたのではないですか?

SECURITY DEFINERは関数所有者のBYPASSRLSに依存するため、所有者のロール設定が変わると静かに効かなくなるうえ、SECURITY DEFINER関数の許可リストを増やすことになる。本件はトリガ本体でset_configによりapp.current_user_roleを一時的にsystem_adminへ切り替え、SELECTの後に必ず元へ戻す方式にして、SECURITY DEFINERにはしなかった。

Q3関数本体でroleを切り替えると、INSERTの詐称防止チェックまで緩まりませんか?

いいえ。roleの切り替えはSELECTの前後だけで、関数を抜けた時点で呼び出し元のroleに戻る。INSERTのWITH CHECK(actor詐称防止)はBEFOREトリガの後、呼び出し元の本来のroleで評価されるため、他人のuser_idをactorに詐称したINSERTは従来どおり拒否される。

確認した環境

  • PostgreSQL / Cloud SQL。0039のコメントに「関数SET句でのカスタムGUC指定はPG15以降superuser限定」と明記あり
  • 2026-05-29(commit e3d5791a)に導入、2026-10-01(commit b4304e92, PR #1422/#1424)に修正。本番適用はskill apply-migrateの人間ゲート待ちで未適用

この記事の根拠

  • SQLファイル 49〜50行目コミット e3d5791
  • SQLファイル 226〜231行目コミット e3d5791
  • SQLファイル 69〜102行目コミット e3d5791
  • SQLファイル 1〜119行目コミット b4304e9
  • TypeScriptファイル 73〜153行目コミット b4304e9

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。