Rebounder Tech Blog

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

system_adminの操作だけ外部キー制約違反(23503)で500になった

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

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

結論

監査ログのactor_user_idがusersテーブルへの外部キーである設計では、別テーブルで管理する管理者ロールの操作を記録しようとした瞬間に外部キー制約違反(23503)で失敗する。

結論

監査ログの actor 参照を1本のテーブルへの外部キーにすると、そのテーブルに存在しない種類の管理者が操作した瞬間だけ失敗します。

症状

運営側の管理者(system_admin)が広告の削除を操作すると、HTTP 500 が返ります。本番ではエラーメッセージがマスクされ、digest 付きの汎用エラーだけが見えます。

同じ画面から広告を新規作成すると、500 にはなりません。代わりに「他の操作と競合しました。最新の内容を読み込み直してください」という、事実と違うメッセージが返ります。作成そのものは失敗しているのに、見た目は正常に握りつぶされています。

削除は落ちて気づかれ、作成は落ちても気づかれない。2つの操作が同じ原因で壊れているのに、症状が対称になっていませんでした。

原因

このシステムには、テナント側の学校管理者(school_admin)と、全校を横断する運営側の管理者(system_admin)という、別のテーブルで管理された2種類の管理者ロールがあります。

監査ログには誰が操作したかを記録する列があり、users(id) への外部キーが付いています。学校管理者は users テーブルの行なので、この参照は素直に効きます。しかし system_adminusers とは別のテーブルで管理されていて、users には存在しません。

削除・作成どちらの Server Action も、監査ログへ書き込む際に操作者の uid をそのまま actor 参照へ渡していました。学校管理者が操作すれば users に実在する行を指すので通りますが、system_admin が操作すると、存在しない users の行を指すことになり、insert の時点で外部キー制約違反(PostgreSQL のエラーコード 23503)になります。

この違反そのものは、作成・削除のどちらの操作でも同じ理由で起きていました。差が出たのは、その先の例外処理です。

  • 作成側は制約違反を捕まえて「競合しました」というメッセージに変換する処理がすでにあったため、外部キー違反でもそのメッセージのまま返っていました。原因が制約違反である点は合っていても、実際に起きているのは他ユーザーとの競合ではなく、この参照設計そのものの不整合です。
  • 削除側には制約違反を捕まえる処理が無く、例外がそのまま再スローされていました。本番環境ではエラー詳細がマスクされるため、operator にはただの HTTP 500 だけが見えます。

既存のユニットテストは DB 接続をモックしていました。system_admin の uid を渡すコード自体は実行されてテストは通ります。しかし外部キー制約は実データベースに書き込んで初めて発火するものなので、モック越しのテストでは検証できていませんでした。CI は緑のまま、この不整合は見逃され続けていました。

さらに、作成側の失敗は表面化しにくい形でした。テスト用の広告データはこの画面とは別の投入方法で作られており、system_admin 自身がこの画面から新規作成を試す機会もほとんどありません。「競合しました」という、もっともらしいエラーメッセージが返ることも、実害として認識されない一因でした。

直す

監査ログの actor 参照を、操作者が system_admin のときは null にするよう変更しました。

const isSystemAdmin = params.actor.role === "system_admin";
const actorRef = isSystemAdmin ? null : params.actor.uid;

操作者を特定する情報自体は消さず、外部キーの無い別カラム(actorIdentityUid)に保持します。users への参照が要る集計・結合には使えなくなりますが、「誰が操作したか」を追う手段は残ります。

削除側の例外処理には、作成側とまったく同じ制約違反の判定を追加しました。これで、同じ原因の失敗が経路によって「500」だったり「競合」だったりする非対称は無くなり、どちらも同じ意味のあるメッセージを返します。

回帰テストでは、system_admin が操作したときに actor 参照が null になり、本人の識別情報は別カラムに残ることを固定しました。あわせて、外部キーの対象外である別のテーブル(adscreated_by など)については、過剰に null 化していないことも確認しています。直すべきは「別テーブルで管理されたロールを users への外部キーで参照している箇所」だけで、それ以外まで一律に緩める必要はありません。

再発しない形にする

この事故が教えているのは、「外部キー制約違反」というエラーコードは同じでも、どの参照が違反しているかによって正しい対処が変わるということです。作成側の例外処理は、制約違反を一括りに「競合」として扱っていたために、原因が違う2つの23503を区別できていませんでした。

再発防止としてやったのは2つです。1つは、上で書いた通り、system_adminusers への外部キーに乗せないという設計そのものの修正です。もう1つは、削除・作成の両方で同じ判定関数(isConstraintViolation)を使い、経路によって例外処理の有無が変わらないようにしたことです。片方だけ直すと、いずれ同じ非対称が別の操作で再現します。

テナントに閉じたロールと、テナントを横断するロールを同じ監査ログで扱う設計そのものは、他の運営系の書き込みにも共通してありえます。同種の箇所は、同じ方針(監査ログの参照が users を前提にしていないか)で洗い出す必要があります。

監査ログの actor 設計については、RLS policy が actor_user_id=NULL を許してしまう別の事故 も書いています。あちらは NULL を許したことが問題で、今回は NULL にすることが解決策でした。同じ列でも、何を守ろうとしているかによって答えが逆になります。

よくある質問

Q1なぜ削除はHTTP 500、作成は「競合」エラーになったのですか?

同じ外部キー制約違反(23503)に対する例外処理が、経路ごとに違ったためです。作成側にはすでに制約違反を捕まえて「他の操作と競合しました」に変換する処理があったため、実際は外部キー違反でもその文面で返っていました。削除側にはその処理が無かったため例外がそのまま再スローされ、本番環境ではエラー内容がマスクされたHTTP 500になりました。

Q2既存のテストがこの不具合を検出できなかったのはなぜですか?

ユニットテストがDB接続をモックしていたためです。system_adminのuidを渡すコード自体は実行され、モック上は成功として扱われます。外部キー制約は実際のデータベースに対して行われる操作でしか発火しないため、モックテストの対象からは外れていました。

Q3作成が実際には失敗していたのに、なぜ気づかれなかったのですか?

テスト用の広告データが、このServer Actionsの経路とは別の投入方法で作られていたためです。system_admin自身がこの画面から新規作成を試す機会がほとんど無く、失敗を握りつぶす「競合」表示が実害として認識されないまま残っていました。

Q4修正では何を変えましたか?

監査ログのactor参照を、操作者がsystem_adminのときはnullにするよう変更しました。本人の識別情報は外部キーの無い別カラムに保持し、削除側の例外処理にも作成側と対称な制約違反の判定を追加しました。

確認した環境

  • Next.js Server Actions / Drizzle ORM / PostgreSQL(audit_log の外部キーは migration 0004 で付与)
  • 2026-06-13 に修正

この記事の根拠

  • TypeScriptファイル 56〜78行目コミット dfc0c5a
  • TypeScriptファイル 159〜199行目コミット dfc0c5a
  • TypeScriptファイル 56〜90行目コミット 069cc05
  • TypeScriptファイル 171〜215行目コミット 069cc05

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