upsert + RLSで「参加した瞬間だけ」42501になる罠
Wavelength(Supabase + PostgreSQLで構築)で、グループへの新規参加が断続的に 42501 new row violates row-level security policy で失敗する不具合を追った記録。App Storeの審査で2回続けて同じ症状(「グループに参加できない」)を指摘され、最終的にはSupabase基盤側の障害まで疑う羽目になったが、実際の原因はもっと単純な、RLS配下での upsert の使い方そのものにあった。
症状
招待コードでグループに参加すると、group_members へのINSERT(実装上は .upsert(payload, onConflict: "group_id,user_id"))が次のエラーで落ちる。
{"code":"42501","message":"new row violates row-level security policy for table \"group_members\""}
RLSポリシー自体は単純で、with_check: auth.uid() = user_id のみ。JWTをデコードして中身を確認しても sub は user_id と一致、role: authenticated、有効期限内。SET LOCAL ROLE authenticated; SET LOCAL request.jwt.claims = '...' で同じ条件を手動で再現すると、SQLレベルでは常に成功する。なのに実際のREST API経由(アプリの通常のSDK呼び出しでも、トークンを自分で明示指定した生のHTTPリクエストでも)は失敗する。
寄り道: Supabase基盤の障害を疑う
ロールの権限周りを疑い、影響のあるポリシーの roles を {authenticated} から {public} に緩和(auth.uid() = user_id のチェック自体は変えていないので安全)。プロジェクトの再起動も複数回。JWT署名鍵をECC(ES256)からHS256(共有鍵)にローテーションしたところ、直後に2回連続で成功したものの、すぐにまた失敗に戻った。
Supabaseダッシュボードのログをエクスポートすると、失敗リクエストの auth_user フィールドには正しいユーザーIDが記録されているのに、同時刻のPostgresログでは auth.uid() ベースのRLSチェックが通っていない、という食い違いが見えた。ちょうど同じタイミングでstatus.supabase.comに「401 errors due to JWT rejections」という進行中のインシデントが載っており、GitHub上にも酷似した報告(supabase/supabase#48302、#47621)があった。ここまで状況証拠が揃うと「これはこちらではどうにもできないプラットフォーム側の問題だ」と結論づけたくなる。実際サポートにも問い合わせたが、送信する前に一度、変更した設定をすべて元に戻し、条件を1つずつ切り分け直すことにした。
本当の原因: ON CONFLICT DO UPDATEはUPDATE側のRLSも要求する
切り分けの過程で、RETURNING 句の有無やロール設定など複数の変数を1つずつ試した中で、決定的な差が出たのは ON CONFLICT DO UPDATE 句そのものの有無だった。
INSERT INTO group_members (...) VALUES (...); — 3/3で成功
INSERT INTO group_members (...) VALUES (...) ON CONFLICT (group_id, user_id) DO UPDATE SET ...; — 3/3で失敗
対象のuser_id・group_idの組み合わせに既存行が存在しない(=実際には競合が起きない)状態でも、後者は確実に失敗した。PostgreSQLの INSERT ... ON CONFLICT DO UPDATE は、実行時に競合が起きるかどうかに関わらず、UPDATE側のRLSポリシー(競合先の既存行を確認するための可視性)を要求する仕様になっている。
group_members のSELECTポリシーは is_group_member(group_id)(=自分がそのグループの既存メンバーかどうか)を要求していた。まだメンバーになっていない人が初めて参加しようとする、まさにその瞬間はこの条件を満たせない。つまり「参加できたか確認するために覗こうとしたら、まだ参加していないので覗けない」という自己矛盾が起きていたことになる。表示名の変更など、既にメンバーである人が同じ upsert を使う経路では、is_group_member() が true になるためこの問題は起きない。
修正
退出時は行ごと削除する設計だったので、新規参加時に本物の競合が発生することはそもそもない。upsert をやめて素の insert に変更し、二重タップ等で万一重複した場合だけ 23505 unique_violation を個別に捕捉して無害化した。既存メンバーの更新経路はもとの upsert のままにしている。
if isNewMembership {
do {
try await client.from("group_members").insert(payload).execute()
} catch {
if let e = error as? PostgrestError, e.code == "23505" { /* 既に参加済み。無視 */ }
else { throw error }
}
} else {
try await client.from("group_members")
.upsert(payload, onConflict: "group_id,user_id").execute()
}
実機・iPadシミュレータ双方で、新規グループ作成・招待コード参加・退出後の再参加のいずれも問題なく動作することを確認した。
持ち帰ったこと
ON CONFLICT DO UPDATEは、実際に競合しなくてもUPDATE側のRLS(USING句の可視性)を要求する。RLS配下のテーブルでupsertを使う時は、INSERT側だけでなくUPDATE側の権限も常に満たせるか考える必要がある- 特に「初めて権限を得る瞬間」(このケースなら「参加した瞬間に初めてメンバーとして見えるようになる」)に
upsertを使うと、権限がまだない状態でUPDATE側の権限を要求されて詰む、という構造的な罠になりやすい - 「ゲートウェイのログでは正しいユーザーが記録されているのに、DB側の権限判定だけおかしい」という状態は、外部サービスの障害を疑いたくなる強い状況証拠になる。実際に一致する公開インシデントや類似のバグ報告が見つかると尚更。ただし、そこで結論を急がず、自分の側の条件を1つずつ削る実験に戻る価値は十分にあった
- 原因不明のRLSエラーは、アプリを経由せず直接SQLで同じ操作を再現し、変数を1つずつ削って切り分けるのが結局一番早い