Skip to content
This repository was archived by the owner on Aug 26, 2026. It is now read-only.
156 changes: 156 additions & 0 deletions supabase/migrations/20260806034559_rls_policies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
-- docs/permissions.md の権限マトリクスと docs/data-model.md「RLSポリシー方針」に基づく
-- RLSポリシー。全ポリシーは authenticated ロールのみを対象とする(このアプリはGoogle SSO
-- ログイン前提で、匿名ユーザー向けの公開閲覧機能は無い)。
--
-- profiles.is_admin を参照する条件は一切書かない(MVPスコープ外。docs/permissions.md参照)。

-- 1. profiles (docs/data-model.md「RLSポリシー方針」)
alter table public.profiles enable row level security;

create policy "profiles_select_all" on public.profiles
for select to authenticated
using (true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs/data-model.md「RLSポリシー方針」の表では profiles のSELECTは
「全ユーザー(公開カラムは限定)」となっていますが、この実装は
using (true) で行全体(= email、is_admin を含む全カラム)を
全認証ユーザーに公開しています。

RLSは行単位の制御しかできないので、カラムを絞るならビュー経由にする、
あるいは列単位のGRANT/REVOKEが必要になるはずです。ドキュメントの
「公開カラムは限定」は意図的に書かれている記述に見えるので、
今回スコープ外として全カラム公開のまま進める判断なのか、
実装漏れなのかを確認したいです(特にemailは他ユーザーに
知られたくない場合がありそうです)。


create policy "profiles_insert_self" on public.profiles
for insert to authenticated
with check (id = auth.uid());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[要確認] 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 にする)方が、このガードの意図と一致すると思います。


create policy "profiles_update_self" on public.profiles
for update to authenticated
using (id = auth.uid())
with check (id = auth.uid());
Comment on lines +29 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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を拒否)
などの対応が必要に見えます。


-- 2. events (docs/data-model.md「RLSポリシー方針」、削除ガードはdocs/data-model.md 2章)
alter table public.events enable row level security;

create policy "events_select_not_deleted" on public.events
for select to authenticated
using (deleted_at is null);
Comment thread
Copilot marked this conversation as resolved.
Resolved

create policy "events_insert_as_owner" on public.events
for insert to authenticated
with check (owner_id = auth.uid());

create policy "events_update_owner_only" on public.events
for update to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());

-- イベント削除(論理削除)のガード。オーナー本人以外の参加者が1人でもいる場合、
-- deleted_at をNULLから設定する更新を拒否する。MVPでは例外を設けない(管理者もバイパス不可)。
create function public.guard_event_deletion()
returns trigger
language plpgsql
set search_path = public
as $$
Comment on lines +99 to +103
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;
$$;
Comment on lines +99 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

削除ガードが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 trigger guard_event_deletion_trigger
before update on public.events
for each row execute function public.guard_event_deletion();

-- 3. event_participants (docs/data-model.md「RLSポリシー方針」、招待の仕様は3章)
alter table public.event_participants enable row level security;

create policy "event_participants_select_own_or_public" on public.event_participants
for select to authenticated
using (user_id = auth.uid() or visibility = 'public');

-- 自分で登録する経路(invited_byなし) と、参加登録済みユーザーによる招待経路
-- (invited_by=自分。招待できるのは参加登録している任意のユーザー)の2通りのみ許可する。
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()
)
)
);
Comment on lines +135 to +150
Comment on lines +135 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 側に明記するか

どちらかで揃えたほうがいいと思います。


create policy "event_participants_update_self" on public.event_participants
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());

create policy "event_participants_delete_self" on public.event_participants
for delete to authenticated
using (user_id = auth.uid());

-- 4. ticket_entries (docs/data-model.md「RLSポリシー方針」。個人スコープ)
alter table public.ticket_entries enable row level security;

create policy "ticket_entries_select_own" on public.ticket_entries
for select to authenticated
using (user_id = auth.uid());

create policy "ticket_entries_insert_own" on public.ticket_entries
for insert to authenticated
with check (user_id = auth.uid());

create policy "ticket_entries_update_own" on public.ticket_entries
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());

create policy "ticket_entries_delete_own" on public.ticket_entries
for delete to authenticated
using (user_id = auth.uid());

-- 5. expenses (docs/data-model.md「RLSポリシー方針」。個人スコープ)
alter table public.expenses enable row level security;

create policy "expenses_select_own" on public.expenses
for select to authenticated
using (user_id = auth.uid());

create policy "expenses_insert_own" on public.expenses
for insert to authenticated
with check (user_id = auth.uid());

create policy "expenses_update_own" on public.expenses
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());

create policy "expenses_delete_own" on public.expenses
for delete to authenticated
using (user_id = auth.uid());

-- 6. budgets (docs/data-model.md「RLSポリシー方針」。個人スコープ)
alter table public.budgets enable row level security;

create policy "budgets_select_own" on public.budgets
for select to authenticated
using (user_id = auth.uid());

create policy "budgets_insert_own" on public.budgets
for insert to authenticated
with check (user_id = auth.uid());

create policy "budgets_update_own" on public.budgets
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());

create policy "budgets_delete_own" on public.budgets
for delete to authenticated
using (user_id = auth.uid());
Comment on lines +217 to +219
Loading