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

公開: 2026-09-02
開発日記SupabaseRLSPostgreSQLテスト設計

ちょうぼっち(accounting-app)の人事労務まわりの画面を5本続けて実装していたら、 「招待したメンバーでログインすると、どの画面も『会社情報が見つかりません』になる」 という報告が出ました。オーナー本人では再現しません。

原因はアプリ側に残っていた1行の絞り込みでした。

3層のうち1層だけ古かった

この画面群では、ユーザーが見られる会社を3つの層で決めています。

DB (RLSポリシー)API (requireMembership)クライアント解決is_org_member(org_id)0053で広げたorganization_members最初から組織単位広げ忘れ.eq("user_id", userId)DBとAPIは「組織のメンバーなら見える」。クライアントだけが「作った本人だけ見える」。結果、viewer は認可を通過しているのに会社が0件で返り、画面は空を表示する。
広げたのはDBとAPIの2層だけで、クライアントの解決処理が取り残されていた。

マイグレーション 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とアプリ側の絞り込み にまとめました。 実装しているプロダクトの中身は ちょうぼっち にあります。