監査ログのentity_idがuuid列なのにトークンを入れて22P02で無記録になった
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
audit_logs.entity_idがuuid列の設計では、64hexトークンのような非uuid文字列をそのまま渡すとinsertがinvalid input syntax for type uuid(22P02)で失敗し、best-effort設計のtry/catchに握りつぶされて記録ごと消える。
結論
監査ログのentity_id列をuuid型にしておきながら、呼び出し側がuuidでない文字列(64hexトークン)をそのまま渡すと、insertはPostgreSQLのエラーコード22P02(invalid input syntax for type uuid)で失敗する。監査記録がbest-effortのtry/catchで囲われていると、このエラーは誰にも見えないまま消え、重要な操作の記録そのものが丸ごと欠ける。
症状
占有枠(loop)の招待リンクを発行・失効する操作は、正常に完了する。招待メールも届くし、リンクも機能する。
ところが、この操作の監査ログを見ても、発行・失効のどちらも1件も記録されていない。「非公開の占有枠を開けるcapability URLの発行・失効」という、鍵の発行に相当する重要な操作にもかかわらず、いつ・誰が発行したのかを後から追う手段が無かった。
エラーも出ない。画面は成功と表示し、例外もログに残らない。何が起きているのかを疑う手がかりが、外側からは無い状態だった。
原因
監査ログのテーブルaudit_logsは、entity_id列をuuid型で定義している。
create table if not exists public.audit_logs (
id uuid primary key default gen_random_uuid(),
actor_id uuid references public.profiles(id) on delete set null,
actor_label text,
action text not null,
entity text not null,
entity_id uuid,
summary text not null,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
招待リンクの発行・失効を記録する側の実装は、このentity_idに招待トークン(64桁の16進数文字列)をそのまま渡していた。
await logAudit({
action: "create",
entity: "share_links",
entityId: issued.invite.token,
// ...
});
64hexのトークンはuuid形式(8-4-4-4-12のハイフン区切り)と一致しないため、PostgreSQLはinsertの時点でこれを拒否する。返るエラーコードは22P02(invalid input syntax for type uuid)で、「uuid型の列に、uuidとして解釈できない文字列を渡した」ことを示す汎用のエラーだ。
問題はここからで、監査記録用の関数logAuditは次のようにtry/catchで全体を囲んでいた。
export async function logAudit(input: AuditInput): Promise<void> {
try {
// ...
await supabase.from("audit_logs").insert({
// ...
entity_id: input.entityId ?? null,
// ...
});
} catch {
// 監査記録の失敗は本処理に影響させない
}
}
catch節はコメントだけで、エラーの再スローもログ出力も一切していない。これは「監査記録の失敗くらいで、招待リンクの発行という本処理そのものを止めたくない」というbest-effort設計の判断であり、それ自体は妥当だ。しかし副作用として、insertが22P02で落ちたという事実そのものが、開発者にもオペレーターにも一切見えなくなる。本処理は成功し、画面も成功を表示し、例外も無い。唯一失われるのは、監査ログの1行だけという、最も気づきにくい壊れ方になっていた。
直す
entity_idには、招待トークンではなく操作対象である占有枠(loop)自身のuuid(loopId)を渡すように変更した。
await logAudit({
action: "create",
entity: "share_links",
// ⚠ audit_logs.entity_id は uuid。64hex のトークンを入れると 22P02 で insert が落ち、
// logAudit の catch に飲まれて無記録になる。枠 id で記録する。
// トークン全文も入れない(監査を読める全員が有効な鍵の保持者になってしまう)。
entityId: loopId,
// ...
metadata: {
loop_id: loopId,
company_id: companyId,
reused: issued.reused,
emailSent,
token_prefix: issued.invite.token.slice(0, 8),
},
});
トークンを完全に記録から外すのではなく、先頭8文字だけをmetadata.token_prefixとして残した。これは単なる妥協ではなく、もう1つの副作用を同時に避けるための設計でもある。監査ログを読める権限を持つ人全員が、トークン全文を見られる場所に置くと、そのまま有効な招待リンクの鍵の保持者になってしまう。entity_idをuuid列に合わせて直すのと同時に、記録する情報の範囲そのものも絞った。
失効(revoke)側の処理も同じ形で、失効対象のトークンではなくloopIdをentity_idに渡すよう揃えている。
再発しない形にする
この事故が教えているのは、「監査ログをbest-effortにする」という設計判断と、「監査ログが実際に書けているかを検証する」という運用は別物だということだ。try/catchで本処理を守ること自体は正しい。だが、catchが完全な無音になっていると、監査ログという仕組み全体が「動いているように見えて、実は何も記録していない」状態のまま気づかれずに運用され続ける。
再発防止として直したのは、entity_idに渡す値をアプリケーション側の識別子(トークンやメールアドレスなど)ではなく、必ずテーブルのuuid主キーに揃えるという方針だ。監査対象のentityごとに「どのカラムをentity_idに渡すか」がその場の判断に委ねられていると、uuid以外の識別子しか持たない種類の操作が増えるたびに、同じ22P02が別の箇所で再現しうる。
best-effortな失敗許容と、失敗を握りつぶして見えなくすることは違う。せめてcatch節でエラー内容を構造化ログに残すか、監視に投げるだけでも、「本処理は止めないが、記録の欠落には誰かが気づける」形にはできる。この修正では出力を追加していないため、その部分は今後の課題として残っている。
よくある質問
Q1なぜinsertのエラーが呼び出し元に一切伝わらなかったのですか?
監査記録用の関数がtry/catchで全体を囲み、catch節では何もせず(コメントのみで)処理を終えていたためです。監査記録の失敗で本処理そのものを止めたくないというbest-effort設計の判断ですが、その副作用として失敗の発生自体が完全に見えなくなっていました。
Q2なぜentity_idにトークンをそのまま入れていたのですか?
招待リンクを発行した直後の処理で、その場で一意に識別できる値として64桁の16進数トークンを使ったためです。audit_logsのentity_id列がuuid型で定義されていることをこの経路では見落としていました。
Q3直し方はトークンを記録に残さないことだったのですか?
いいえ。entity_idには操作対象である占有枠(loop)のuuidを入れるよう変更し、トークンは全文ではなく先頭8文字だけをmetadataのtoken_prefixとして残しました。監査ログを読める人全員がそのまま有効な招待リンクの鍵を持つことになる問題も同時に避けています。
Q4同じ22P02は他の原因でも起きますか?
はい。22P02はPostgreSQLがuuid型として解釈できない文字列を渡されたときに返す汎用のエラーコードで、entity_idに限らずuuid列全般で、想定外の形式の文字列を渡せば同じコードで失敗します。
確認した環境
- Next.js Server Actions / Supabase(PostgreSQL) — kimiteras-portal
- 2026-06-12に導入・2026-07-24のレビューで発覚し修正
この記事の根拠
- TypeScriptファイル 879〜887行目コミット e4985c0
- TypeScriptファイル 882〜899行目コミット acadcbc
- TypeScriptファイル 18〜35行目コミット e4985c0
- SQLファイル 3〜13行目コミット c8632cc
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。