RLSを広げたのに、アプリ側の絞り込みが残っていた

ちょうぼっち(accounting-app)の人事労務まわりの画面を5本続けて実装していたら、 「招待したメンバーでログインすると、どの画面も『会社情報が見つかりません』になる」 という報告が出ました。オーナー本人では再現しません。
原因はアプリ側に残っていた1行の絞り込みでした。
3層のうち1層だけ古かった
この画面群では、ユーザーが見られる会社を3つの層で決めています。
マイグレーション 0053 で companies のSELECTポリシーを
作成者のみ から is_org_member(organization_id) へ広げていました。
APIの認可も organization_members を見ています。
ところが、ログイン直後に使う会社を決める resolveCompanyForUser だけが、
const primary = await supabase
.from("companies")
.select(companySelectFields)
.eq("user_id", userId) // ← これが残っていた
.order("created_at", { ascending: true });
と、作成者で絞ったままでした。
エラーにならないのが厄介だった
権限エラーが出るなら気づけます。この不具合は0件が正常に返るので、 アプリからは「会社を1つも持っていない新規ユーザー」と区別がつきません。 だから画面は例外を投げず、素直に空の状態を出します。 「権限まわりのバグはエラーで顕在化する」という思い込みが、切り分けを遅らせました。
RLSが有効なテーブルに対してアプリ側でも同じ条件を書くと、条件は AND で重なります。 DB側を緩めても、アプリ側が厳しいままなら見える範囲は広がりません。 そして両方に同じ意図を書いている限り、片方を直したときにもう片方が置き去りになります。
直し方は条件を消すこと
修正は .eq("user_id", userId) を外してRLSに任せるだけです。
会社が1件も無いときの新規作成では、作成者を記録する必要があるので user_id は入れたまま残しました。
「絞り込みには使わないが、作成者の記録としては持つ」という役割の分離です。
テストは実ポリシー越しに書いた
ここが今回いちばん考えたところです。 サービスロールキーでDBに触るテストは、RLSを迂回してしまうのでこの不具合を絶対に検出できません。 モックしたクライアントでも同じです。
そこで、実際のユーザーのJWTでSupabaseに接続し、本物のポリシーを通すテストを足しました。 固定したのは3つです。
| 固定した挙動 | なぜ必要か |
|---|---|
| viewer ロールが会社を解決できる | 今回の不具合そのもの |
| 他組織の会社は返らない | 絞り込みを外したことで漏れていないか |
| オーナーの見え方が変わらない | 直したつもりで既存を壊していないか |
2番目が要点です。「見えるようにする」修正では、うっかり見えすぎていないかを 同じテストで必ず押さえないと、緩めた方向のバグに気づけません。
あわせて、開発用のシード(seed-hr-sandbox.mjs)に hr_role が null のメンバー を追加しました。
権限が足りないユーザーが手元に居ないと、403表示の確認が永久に後回しになります。
「弾かれる人」をシードに常駐させるのは、今後もやっていく方針です。
学んだこと
同じ認可条件を複数の層に書いたら、それは必ず片方だけ古くなる前提で置く。 どうしても書くなら、どの層が正本なのかをコードコメントに残しておく。 今回は修正した関数の上に「絞り込みはRLSに任せる。user_idで絞ると招待メンバーが到達できない」と 理由ごと書き残しました。次に読む人(あるいは次のエージェント)が同じ条件を戻さないためです。
手順として再利用できる形に整理したものは Supabase RLSとアプリ側の絞り込み にまとめました。 実装しているプロダクトの中身は ちょうぼっち にあります。