[Phase1] RLSポリシー実装(権限マトリクス準拠) - #31
Conversation
docs/permissions.mdの権限マトリクスとdocs/data-model.mdの 「RLSポリシー方針」に基づき、全6テーブルにRLSを実装。 - profiles: 全員SELECT可、本人のみINSERT/UPDATE - events: 未削除は全員SELECT可、owner_idのみINSERT/UPDATE - event_participants: 本人の行+公開行のSELECT、自己登録/招待のINSERT、本人のみUPDATE/DELETE - ticket_entries/expenses/budgets: 本人のみ全操作 - イベント削除ガード(オーナー以外の参加者がいたら削除不可)はトリガーで実装 (RLSのWITH CHECKだけではOLD/NEWの差分判定が煩雑なため) - profiles.is_adminを参照する条件は一切書いていない(MVPスコープ外) test/db/での検証は別Issue(#26)。 Closes #25
| create function public.guard_event_deletion() | ||
| returns trigger | ||
| language plpgsql | ||
| set search_path = public | ||
| as $$ | ||
| begin | ||
| if new.deleted_at is not null and old.deleted_at is null then | ||
| if exists ( | ||
| select 1 | ||
| from public.event_participants ep | ||
| where ep.event_id = new.id | ||
| and ep.user_id <> new.owner_id | ||
| ) then | ||
| raise exception 'event % has participants other than the owner and cannot be deleted', new.id; | ||
| end if; | ||
| end if; | ||
| return new; | ||
| end; | ||
| $$; |
There was a problem hiding this comment.
削除ガードがRLS越しに沈黙してすり抜ける可能性があります。
guard_event_deletion() は security definer を付けていないため、内部の
exists (select 1 from public.event_participants ep where ...) は
呼び出し元(イベントを削除しようとしているオーナー)の権限、つまり
event_participants_select_own_or_public ポリシーの下で実行されます。
このポリシーは user_id = auth.uid() or visibility = 'public' の行しか見せません。
つまり、オーナー以外の参加者が visibility = 'private' で参加登録している場合、
このexists検査からはその行が見えず「参加者はいない」と判定されてしまい、
docs/data-model.md 2章・docs/permissions.md が要求する
「オーナー本人以外の参加者が1人でもいる場合は削除不可(例外なし)」の
ガードを静かにすり抜けて論理削除が成功してしまいます。
同じマイグレーション内の handle_new_user() (20260806003241_initial_schema.sql)は
まさに同じ理由(呼び出し元のRLSに関わらず内部で判定する必要がある)で
security definer を付けています。こちらの関数にも同様に
security definer が必要ではないでしょうか。
docs/permissions.md の「最小の検証セット」に "参加者がいるイベントをオーナーが削除 → 失敗" が
挙げられていますが、参加者のvisibilityがprivateのケースでこのテストを書けば
今のロジックのままでは赤くなるはずです。
| create policy "profiles_update_self" on public.profiles | ||
| for update to authenticated | ||
| using (id = auth.uid()) | ||
| with check (id = auth.uid()); |
There was a problem hiding this comment.
profiles_update_self が is_admin カラムの書き換えを制限していません。
using (id = auth.uid()) / with check (id = auth.uid()) は行の所有者しか
見ていないため、本人が自分の行に対して
update profiles set is_admin = true where id = auth.uid();を実行すると通ってしまいます。
docs/permissions.md は「is_adminを参照する権限判定はRLSにもアプリ層にも一切書かない」と
明記していますが、これは「参照して分岐を作るな」という話であって、
「本人が自分でis_admin列を書き換えられる状態のまま放置してよい」ことまでは
意味していないはずです。現時点ではどこもis_adminを参照していないので実害はまだ
顕在化しませんが、フェーズ2で管理者判定を実装した瞬間、今のうちに
is_admin=trueを自分でセットしておいたユーザーがそのまま管理者権限を得ます。
eventsテーブルの削除ガードと同様に、is_adminの変更だけは
本人のUPDATEでも弾く(例: BEFORE UPDATEトリガーでold.is_admin <> new.is_adminを拒否)
などの対応が必要に見えます。
| create policy "profiles_select_all" on public.profiles | ||
| for select to authenticated | ||
| using (true); |
There was a problem hiding this comment.
docs/data-model.md「RLSポリシー方針」の表では profiles のSELECTは
「全ユーザー(公開カラムは限定)」となっていますが、この実装は
using (true) で行全体(= email、is_admin を含む全カラム)を
全認証ユーザーに公開しています。
RLSは行単位の制御しかできないので、カラムを絞るならビュー経由にする、
あるいは列単位のGRANT/REVOKEが必要になるはずです。ドキュメントの
「公開カラムは限定」は意図的に書かれている記述に見えるので、
今回スコープ外として全カラム公開のまま進める判断なのか、
実装漏れなのかを確認したいです(特にemailは他ユーザーに
知られたくない場合がありそうです)。
レビュー総評
一方で、インラインコメントで指摘した3点のうち特に1点目は設計上の抜け穴と考えています。
プロセス面: このPRは実装のみで インラインコメント3件はいずれも |
There was a problem hiding this comment.
Pull request overview
Issue #25 に対応し、docs/permissions.md / docs/data-model.md の権限マトリクスに基づいて、Supabase の6テーブルに対するRLS有効化とポリシー定義、およびイベント論理削除のガード用トリガーを追加するPRです。
Changes:
profiles / events / event_participants / ticket_entries / expenses / budgetsにENABLE ROW LEVEL SECURITYと各CRUDポリシーを追加events.deleted_at更新による論理削除を、参加者条件で拒否するBEFORE UPDATEトリガー関数を追加
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| create policy "profiles_select_all" on public.profiles | ||
| for select to authenticated | ||
| using (true); |
| create function public.guard_event_deletion() | ||
| returns trigger | ||
| language plpgsql | ||
| set search_path = public | ||
| as $$ |
| create policy "event_participants_insert_self_or_invite" on public.event_participants | ||
| for insert to authenticated | ||
| with check ( | ||
| (user_id = auth.uid() and invited_by is null) | ||
| or ( | ||
| invited_by = auth.uid() | ||
| and exists ( | ||
| select 1 | ||
| from public.event_participants ep | ||
| where ep.event_id = event_participants.event_id | ||
| and ep.user_id = auth.uid() | ||
| ) | ||
| ) | ||
| ); |
…参照、招待時のvisibility) - guard_event_deletion()にsecurity definerを追加。RLS越しに実行されると private参加者が見えず削除ガードがすり抜けるため - profiles.is_adminを本人のUPDATEで書き換えられないようにするトリガーを追加 (service_roleは除外し、将来の管理者操作を妨げない) - profilesのSELECTを本人のみに限定し、他ユーザーへの公開はid/display_nameのみの profiles_publicビュー経由にする(「公開カラムは限定」の実現) - eventsのSELECTに、削除後もオーナー自身と支出を持つユーザーは参照できる条件を追加 (docs/data-model.md「支出から辿ってイベント名は常に参照できる」との整合) - event_participantsの招待経路INSERTで、招待者がvisibility/participation_stateを 任意に指定できないよう private/joinedに固定
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
supabase/migrations/20260806034559_rls_policies.sql:10
- コメントで参照しているビュー名が
public_profilesになっていますが、このマイグレーションで作成しているのはprofiles_publicです。後から読むと混乱するのでコメント側も実体に合わせてください。
-- SELECTの「全ユーザー(公開カラムは限定)」はRLS(行単位)だけでは表現できないため、
-- テーブル本体は本人のみに限定し、他ユーザーへの公開はpublic_profilesビュー(id/display_nameのみ)
-- 経由に限定する。
| create policy "budgets_delete_own" on public.budgets | ||
| for delete to authenticated | ||
| using (user_id = auth.uid()); |
| -- is_adminは本人のUPDATEでも書き換えられないようにする。参照して分岐を作ってはいないが | ||
| -- (docs/permissions.mdの禁止範囲)、書き換え自体を放置すると、フェーズ2で管理者判定を | ||
| -- 実装した瞬間に「今のうちに自分でis_admin=trueにしておいたユーザー」がそのまま | ||
| -- 管理者権限を得てしまう。service_role(将来の管理者操作用)はauth.role()で除外する。 | ||
| create function public.guard_is_admin_immutable() | ||
| returns trigger | ||
| language plpgsql | ||
| set search_path = public | ||
| as $$ | ||
| begin | ||
| if new.is_admin is distinct from old.is_admin and auth.role() <> 'service_role' then | ||
| raise exception 'is_admin cannot be changed by the profile owner'; | ||
| end if; | ||
| return new; | ||
| end; | ||
| $$; | ||
|
|
||
| create trigger guard_is_admin_immutable_trigger | ||
| before update on public.profiles | ||
| for each row execute function public.guard_is_admin_immutable(); |
There was a problem hiding this comment.
このguard_is_admin_immutableはnew.is_admin is distinct from old.is_adminという条件でis_adminを直接参照しています。意図(本人による書き換え防止)は理にかなっていて、権限判定にis_adminを使っているわけではないので実質的にはdocs/permissions.mdの禁止事項(「これを参照する権限判定は一切実装しない」)には抵触しないと考えますが、ファイル冒頭5行目の「profiles.is_adminを参照する条件は一切書かない」というコメント自体とは文字通り矛盾しています。
- このガード自体は
docs/data-model.md/docs/permissions.mdのどちらにも記載がなく、権限マトリクスにもRLS方針表にも無いロジックです。フェーズ2の「同じPRで両層+テストを変更する」ルールの対象外だとしても、少なくともdocs/data-model.mdのis_adminの注記に一行追記しておかないと、次にこのカラムを触る人が「参照する条件は無いはず」という前提で読んでしまいそうです。 - このトリガーの otherwise/service_role分岐(
auth.role() <> 'service_role')も含めて、test/db/側のテストが本PRには無いので(#26で対応予定とのことですが)、少なくとも「本人がis_adminをtrueに書き換えようとして失敗する」ケースは他のRLS検証と合わせて#26でカバーされるよう明記しておくと安心です。
| -- 未削除の全イベントに加えて、削除後もオーナー自身と、その支出(expenses)を持つ | ||
| -- ユーザーは引き続き参照できるようにする(docs/data-model.md 5章「eventsは論理削除なので、 | ||
| -- 支出から辿ってイベント名などは常に参照できる」)。 | ||
| create policy "events_select_not_deleted_or_referenced" on public.events | ||
| for select to authenticated | ||
| using ( | ||
| deleted_at is null | ||
| or owner_id = auth.uid() | ||
| or exists ( | ||
| select 1 | ||
| from public.expenses e | ||
| where e.event_id = events.id | ||
| and e.user_id = auth.uid() | ||
| ) | ||
| ); |
There was a problem hiding this comment.
このSELECTポリシーはdocs/data-model.md「RLSポリシー方針」表のevents行(「全ユーザー(deleted_at IS NULL)」)より広く、owner_id = auth.uid()と「自分のexpensesが参照しているイベント」の場合も削除後に見えるようにしています。根拠のコメント(5章の「支出から辿ってイベント名などは常に参照できる」)は妥当で、実装自体に異論はないのですが、この例外はdocs/data-model.mdのRLSポリシー方針表にもdocs/permissions.mdの権限マトリクスにも明文化されていません。
ドキュメント駆動の運用(CLAUDE.md)である以上、この表を「正」として読む次の実装者・レビュアーが「削除済みイベントは誰からも見えないはず」と誤解しないよう、docs/data-model.mdの方針表に一行反映しておくことを推奨します。
レビュー総評
良い点
気になった点(インラインコメント参照)
テストについて(ブロッキングではないが要確認)このPRは
以上、致命的な問題は見当たりませんでしたが、ドキュメントとの整合性・テスト未了の点は認識合わせのためコメントしました。 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
supabase/migrations/20260806034559_rls_policies.sql:10
- コメントで参照しているビュー名が
public_profilesになっていますが、このマイグレーションで作成しているのはprofiles_publicです。後から検索・運用時に混乱するのでコメント側も実名に合わせてください。
-- SELECTの「全ユーザー(公開カラムは限定)」はRLS(行単位)だけでは表現できないため、
-- テーブル本体は本人のみに限定し、他ユーザーへの公開はpublic_profilesビュー(id/display_nameのみ)
-- 経由に限定する。
supabase/types.ts:78
budgets_user_id_fkeyは初期スキーマ上public.budgets.user_id -> public.profiles.idの外部キーですが、この生成型では同じ FK 名でprofiles_publicへの Relationship も追加されています。実DB上はビューにFKを張れないため、この Relationship は実際のリレーションを表しておらず、型上の join が実行時に失敗する/誤解を招く可能性があります。supabase/types.tsは手編集せず、マイグレーション適用後にyarn gen:typesの生成結果をそのままコミットして整合させてください。
foreignKeyName: "budgets_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles_public"
referencedColumns: ["id"]
supabase/types.ts:136
event_participants_invited_by_fkeyでもprofilesに加えてprofiles_publicへの Relationship が同じ外部キー名で並んでいます。初期スキーマではinvited_byはpublic.profiles(id)を参照しており、profiles_publicはビューなのでFKの参照先にはなりません。上と同様に、生成型が実スキーマと矛盾していないか確認し、必要ならyarn gen:typesの生成物に合わせてください。
foreignKeyName: "event_participants_invited_by_fkey"
columns: ["invited_by"]
isOneToOne: false
referencedRelation: "profiles_public"
referencedColumns: ["id"]
- auto_expose_new_tables=false(既定)のため、RLSポリシーだけでは authenticatedロールがData API経由でテーブルに一切アクセスできない ことが判明。全6テーブルにGRANTを追加(レビュー指摘対応、重大な抜け) - ファイル冒頭のコメントを整理: is_adminガードは「参照する権限判定」 ではなく列の書き換え保護であることを明記 - docs/data-model.mdのRLSポリシー方針表・profiles章を実装内容 (profiles_publicビュー、削除済みイベントの例外閲覧、招待時の visibility/participation_state固定、is_admin列保護)に合わせて更新
| create policy "profiles_insert_self" on public.profiles | ||
| for insert to authenticated | ||
| with check (id = auth.uid()); |
There was a problem hiding this comment.
[要確認] is_admin の保護がUPDATEのみでINSERTには効いていない
guard_is_admin_immutable_trigger は before update にしか張られていないため、profiles_insert_self の with check は id = auth.uid() だけで is_admin の値を制限していません。理屈上はこのポリシー経由で is_admin = true の行をINSERTできてしまい、35行目付近のコメントで説明されている「自分で is_admin=true にしておく抜け道を塞ぐ」という目的をINSERT側では達成できていません。
現状は handle_new_user()(初期マイグレーション)がサインアップ時に id, email のみで行を自動作成するため、同じ id で profiles_insert_self 経由の追加INSERTはPK重複で失敗し、かつ profiles にはDELETEポリシーが無いため本人がその行を消して作り直すこともできず、実害はほぼ顕在化しません。ただしこれは「サインアップ時に必ず行が先に作られる」という別の実装(auth.usersトリガー)に暗黙に依存した安全性で、このポリシー単体では守れていません。将来profilesの作成経路が変わった場合の静かな抜け穴になり得るので、with check に and is_admin = false を加える(または guard_is_admin_immutable_trigger を before insert or update にする)方が、このガードの意図と一致すると思います。
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
supabase/migrations/20260806034559_rls_policies.sql:16
- コメント内で参照しているビュー名が
public_profilesになっていますが、このマイグレーションで作成しているのはprofiles_publicです。識別子が一致していないと、後から検索・参照するときに混乱します。
-- テーブル本体は本人のみに限定し、他ユーザーへの公開はpublic_profilesビュー(id/display_nameのみ)
-- 経由に限定する。
supabase/migrations/20260806034559_rls_policies.sql:46
profiles_update_selfにより本人がprofiles.emailを更新できてしまいます。emailは招待の検索キーとして使う想定 (docs/data-model.md) なので、本人が任意の値に変更できると招待の宛先の乗っ取り/サインアップ阻害(UNIQUE衝突)などに繋がります。is_adminと同様に、emailも本人のUPDATEでは変更不可にするガードを追加してください。
begin
if new.is_admin is distinct from old.is_admin and auth.role() <> 'service_role' then
raise exception 'is_admin cannot be changed by the profile owner';
end if;
return new;
レビュー総評
CLAUDE.mdの禁止事項について 過去の指摘が解消されていることを確認
権限マトリクスとSQLを1行ずつ突き合わせましたが、 新規の指摘(インラインコメント参照) テストについて(既存指摘の再確認・重要度は変わらず)
以上、致命的なブロッカーとまでは考えていませんが、インラインで指摘した1点の確認と、テスト未了の状態を認識した上でのマージ判断をお願いします。 |
guard_is_admin_immutable(UPDATE)との非対称性を解消(レビュー指摘対応)。
| create policy "event_participants_insert_self_or_invite" on public.event_participants | ||
| for insert to authenticated | ||
| with check ( | ||
| (user_id = auth.uid() and invited_by is null) | ||
| or ( | ||
| invited_by = auth.uid() | ||
| and visibility = 'private' | ||
| and participation_state = 'joined' | ||
| and exists ( | ||
| select 1 | ||
| from public.event_participants ep | ||
| where ep.event_id = event_participants.event_id | ||
| and ep.user_id = auth.uid() | ||
| ) | ||
| ) | ||
| ); |
There was a problem hiding this comment.
event_participants_insert_self_or_invite の招待経路は「招待者自身が event_participants に行を持つこと」を必須にしています。一方 docs/data-model.md 2章は owner_id を「参加者ではない」役割として明示的に定義しており(「登録だけして自分は参加しない、というケースも表現できる」)、docs/permissions.md の権限マトリクスは「他ユーザーの招待」で「オーナー(登録者)」列を無条件で ○ としています。
このポリシーのままだと、自分自身は参加登録していないオーナーが誰かを招待しようとするとRLSに弾かれ、マトリクスの「オーナー ○」と食い違うケースが生まれます(オーナー = 参加登録済みの前提が常に成り立つなら問題ありませんが、data-model.md はその前提を明示的に否定しています)。
owner_id = auth.uid()を許可条件に追加してマトリクス通りにするか- あるいは「オーナーも招待するには自分で参加登録している必要がある」という制約を
docs/permissions.md/docs/data-model.md側に明記するか
どちらかで揃えたほうがいいと思います。
|
|
||
| -- 1. profiles (docs/data-model.md「RLSポリシー方針」) | ||
| -- SELECTの「全ユーザー(公開カラムは限定)」はRLS(行単位)だけでは表現できないため、 | ||
| -- テーブル本体は本人のみに限定し、他ユーザーへの公開はpublic_profilesビュー(id/display_nameのみ) |
There was a problem hiding this comment.
nit: コメント中の「public_profilesビュー」は実際に作成しているビュー名 profiles_public(58行目)と語順が逆です。後で grep で追えなくなるので直しておくと良さそうです(59行目コメントの「所有者(postgres)としてprofilesを参照するため」の段落は正しい名前になっています)。
レビュー総評
インラインで2件コメントしました。
懸念点(ブロッカーではない)PR説明にある通り、 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
supabase/migrations/20260806034559_rls_policies.sql:73
- PR説明の箇条書きでは「events は
deleted_at IS NULLの行のみ全員SELECT可」となっていますが、ここでは削除済みでも「オーナー本人」または「そのイベントを参照する expenses を持つユーザー」はSELECTできるポリシーになっています。PR説明側もこの実際の挙動に合わせて更新した方が、レビュー/後追い時の齟齬を避けられます。
-- 未削除の全イベントに加えて、削除後もオーナー自身と、その支出(expenses)を持つ
-- ユーザーは引き続き参照できるようにする(docs/data-model.md 5章「eventsは論理削除なので、
-- 支出から辿ってイベント名などは常に参照できる」)。
create policy "events_select_not_deleted_or_referenced" on public.events
for select to authenticated
supabase/migrations/20260806034559_rls_policies.sql:16
- コメント内で参照しているビュー名が
public_profilesになっていますが、このマイグレーションで作っているビューはprofiles_publicです。将来検索/運用時に混乱するので名前を揃えてください。
-- SELECTの「全ユーザー(公開カラムは限定)」はRLS(行単位)だけでは表現できないため、
-- テーブル本体は本人のみに限定し、他ユーザーへの公開はpublic_profilesビュー(id/display_nameのみ)
-- 経由に限定する。
PR #68 のClaude Reviewの指摘に対応。指摘は正しかった。 誤り: 「300行を超えた5本」は事実と異なり、素の差分では9本だった (#31 307 / #59 365 / #60 380 / #30 441 / #29 617 / #18 679 / #41 698 / #32 1063 / #16 3528)。同じ本文が書いていた「300行以下が65%(17/26)」は 超過9本を含意しており、記述が自己矛盾していた。 原因: 母集団の統計(中央値・65%)は素の差分で数え、外れ値の説明だけに 除外規則を適用していた。数え方を混在させたうえ、超過リストを上位5本で 打ち切って全件確認しなかった。 訂正: 26本すべてを git show --numstat で数え直し、除外の段階ごとに 表で示す。素の差分(中央値135行 / 65% / 超過9本)、パス名で機械的に 判定できる除外まで(中央値128行 / 85% / 超過4本)、除外規則を最後まで 適用(超過2本 = #59・#60)。落ちる7本の内訳も明記した。 あわせて、3段階目の中央値を出さない理由を書いた。supabase/config.toml は #29 では supabase init の出力(416行)、#56 では根拠コメント付きで手で 直した6行で、同じパスでも扱いが逆になる。パス名では決まらないことが、 この節が機械的ゲートになり得ない理由そのものなので、「lintではない」の 段落の根拠もこの実測に差し替えた。 Refs #44
* docs: Issueの粒度とPR差分サイズの目安をCLAUDE.mdに明文化 判断ポイントは1 Issueに3個まで(5個超で分割)、PR差分は300行を目安とする。 記事の実測値をそのまま採らず、main にマージ済みのPR 26本(中央値135行、 300行以下65%)で裏を取ってから採用した。行数の数え方から生成物・ロック ファイル・権限マトリクスを写したテスト表を除外する根拠も、超過した PR #16 / #32 の実態から示した。 機械的ゲートにしない旨と、3回ルール(モデルを上げる) / PO確認(判断を 下せる層に上げる) / 粒度超過(Issueを分ける)の対処の違いを表で整理。 docs/roadmap.md はポインタ1行に留め、根拠は1箇所にだけ置く。 Refs #44 * docs: 判断ポイント数の境界を一本化し、裏取り済みの数値と外部実測を書き分ける PR #68 のClaude Reviewの指摘2件に対応。 指摘1: 「3個まで、5個を超えるなら分割」で4個の扱いが未定義だった。 閾値を「3個まで。4個目が出てきたら分ける」に一本化する。機械的ゲートに しない方針である以上、「検討」と「必ず」の二段構えは実効性のない false precisionになるため、緩衝域を作らず単一の線にした。 指摘2: 裏取り済みの300行と、外部実測のままの3個が同じ文脈に並んでいた。 「2つの数字は裏付けの強さが違う」として段落を分け、300行はこのリポジトリの 実測(PR 26本、中央値135行/300行以下65%)で検証済み、3個は外部実測のみを 根拠とする未検証のヒューリスティックであると明示した。過去Issueの判断数は 記録がなく後から数え直せないため、このリポジトリでの裏取りが今はできない 理由も併記。採用の根拠はコストの非対称性に置いた。 あわせて、外部実測に対応値のない4個/5個を推定して線を引いていないことと、 実績が溜まったら見直す旨を記載した。 Refs #44 * docs: PR実測の集計を数え直し、超過本数の誤りを訂正する PR #68 のClaude Reviewの指摘に対応。指摘は正しかった。 誤り: 「300行を超えた5本」は事実と異なり、素の差分では9本だった (#31 307 / #59 365 / #60 380 / #30 441 / #29 617 / #18 679 / #41 698 / #32 1063 / #16 3528)。同じ本文が書いていた「300行以下が65%(17/26)」は 超過9本を含意しており、記述が自己矛盾していた。 原因: 母集団の統計(中央値・65%)は素の差分で数え、外れ値の説明だけに 除外規則を適用していた。数え方を混在させたうえ、超過リストを上位5本で 打ち切って全件確認しなかった。 訂正: 26本すべてを git show --numstat で数え直し、除外の段階ごとに 表で示す。素の差分(中央値135行 / 65% / 超過9本)、パス名で機械的に 判定できる除外まで(中央値128行 / 85% / 超過4本)、除外規則を最後まで 適用(超過2本 = #59・#60)。落ちる7本の内訳も明記した。 あわせて、3段階目の中央値を出さない理由を書いた。supabase/config.toml は #29 では supabase init の出力(416行)、#56 では根拠コメント付きで手で 直した6行で、同じパスでも扱いが逆になる。パス名では決まらないことが、 この節が機械的ゲートになり得ない理由そのものなので、「lintではない」の 段落の根拠もこの実測に差し替えた。 Refs #44 * docs: PR #29/#32の除外理由が2段階なのに1段階しか書いていなかった記述漏れを修正 Claude Reviewの指摘どおり、#29(617→22)はsupabase/config.toml(416行) だけでなくsupabase/types.ts(179行)も、#32(1063→79)はテスト表(924行) だけでなくyarn.lock(60行)も除外して初めて数字が再現できる。 片方しか書いていなかったため、追試すると数値が合わなかった。 Refs #44
概要
Issue #25 の対応。
docs/permissions.mdの権限マトリクスとdocs/data-model.md「RLSポリシー方針」に基づき、全6テーブルにRLSポリシーを実装する。権限の設計自体は決定済みで、このPRは実装のみ。変更内容
supabase/migrations/20260806034559_rls_policies.sqlを追加。profiles: 全員SELECT可、本人のみINSERT/UPDATEevents:deleted_at IS NULLの行のみ全員SELECT可、owner_idのみINSERT/UPDATEevent_participants: 本人の行 +visibility='public'の行のSELECT、自己登録(invited_by IS NULL)または参加登録済みユーザーによる招待(invited_by=自分)のみINSERT、本人のみUPDATE/DELETEticket_entries/expenses/budgets: 本人のみ全操作BEFORE UPDATEトリガーで実装。RLSのWITH CHECKだけだとOLD/NEWの差分(「deleted_atをNULLから設定する更新か」)の判定がしづらいためto authenticatedのみを対象(このアプリはGoogle SSOログイン前提で、匿名ユーザー向けの公開閲覧機能は無い)profiles.is_adminを参照する条件は一切書いていない(docs/permissions.mdでMVPスコープ外と明記)このPRに含まないもの:
test/db/でのRLS検証テスト → 別Issue(#26)注意
この環境にはDockerが無く、ポリシーが実際に意図通り機能するかはローカルで確認できない。CI(
db-testジョブでマイグレーションが構文エラーなく適用されるか)で検証する。実際の権限マトリクス通りの動作検証(否定側を含む)は#26で行う。Test plan
db-testジョブでマイグレーション(RLSポリシー・トリガー含む)が構文エラーなく適用される