既存ポリシーを置換してから適用する

所属と権限をサーバー側で確定する。本人のIDだけで任意チームへの加入や管理者権限を許可しない。
本人のIDだけで任意チームへの加入や管理者権限を許可しない。

この例を既存DBへ追加するだけでは修正になりません。pg_policiesで対象テーブルの既存ポリシーを確認し、再帰するSELECTと自己加入を許すINSERTを同じmigrationで削除・置換します。複数のpermissiveポリシーはORで評価されるため、WITH CHECK(false)を追加するだけでは古い許可を打ち消せません。対象名と依存関係を調べ、バックアップと復元手段を確認してから変更します。

select schemaname, tablename, policyname, permissive, cmd, qual, with_check
from pg_policies
where schemaname = 'public' and tablename in ('team_members', 'sessions');
-- 棚卸しした旧policyを、実際の名前でDROP POLICYしてから置換する。
-- この抜粋は既存policyを自動削除するmigrationではない。

RLS で先に守る境界

マルチテナントの分離では、テーブルから同じテーブルを読むポリシーを書かない。team_members の所属確認は private schema の SECURITY DEFINER 関数に閉じ込め、関数の search_path を固定する。関数所有者が対象表のRLSを迂回できることが必要です。Supabaseの信頼済みmigrationロール等を確認し、FORCE ROW LEVEL SECURITYの有無も調べます。所有者もRLSを受ける構成では、SECURITY DEFINERだけで再帰は解消しません。関数をanonへ許可せず、private schemaをData APIの公開schemaへ追加しないでください。

-- 新規構成を示す抜粋。team_members(team_id uuid, user_id uuid, role text)
-- とsessions(team_id uuid)が存在し、対象の権限設定を確認済みであることが前提。
create schema if not exists private;
revoke all on schema private from public;

create or replace function private.is_team_member(target_team_id uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
  select exists (
    select 1 from public.team_members m
    where m.team_id = target_team_id and m.user_id = auth.uid()
  );
$$;

revoke all on function private.is_team_member(uuid) from public;
grant usage on schema private to authenticated;
grant execute on function private.is_team_member(uuid) to authenticated;

alter table public.team_members enable row level security;
create policy "members can read their team roster"
on public.team_members for select to authenticated
using ((select private.is_team_member(team_id)));

alter table public.sessions enable row level security;
create policy "members can read sessions in their team"
on public.sessions for select to authenticated
using ((select private.is_team_member(team_id)));
create policy "members can create sessions in their team"
on public.sessions for insert to authenticated
with check ((select private.is_team_member(team_id)));

SECURITY DEFINER は強い権限を持つ。関数に任意 SQL や任意 schema を渡さず、表・列を完全修飾し、所有者の権限を最小化する。private schemaはData APIへ公開せず、ポリシーからだけ参照します。招待受諾RPCはtokenをロックし、宛先・期限・未使用を検証して同一トランザクションで確定する必要があります。このRPCの完成実装は掲載していません。

加入をクライアント INSERT に任せない

WITH CHECK (user_id = auth.uid()) だけの INSERT ポリシーは、利用者が知っている team_id を指定して自分を owner として追加できる。team_members へのクライアント INSERT は許可しない。チーム作成と招待受諾は、招待 token の有効期限・宛先・一回利用を検証する RPC または信頼サーバーで実行し、role はサーバー側で固定する。

-- table権限でもクライアントからの直接の所属変更を禁止する。
revoke insert, update, delete on public.team_members from anon, authenticated;
-- 旧許可policyの削除・置換を済ませた新規構成用の例。
create policy "no direct membership inserts"
on public.team_members for insert to authenticated
with check (false);

-- accept_invite(invite_token) のような RPC では、token をロックして
-- 招待先 auth.uid、期限、未使用状態を検証した後に role='member' を固定する。
-- owner 付与は create_team など別の信頼済み経路に限定する。

テストと性能確認

ポリシーの正しさは UI の表示だけで判断しない。少なくとも member は自チームを読める、non-member は空結果、任意 team_id の加入と owner 指定が失敗する、招待 token の再利用が失敗する、という統合テストを用意する。性能のミリ秒値は環境・インデックス・クエリで変わるため掲載しない。team_members(user_id, team_id) など必要なインデックスを置き、実データ量で EXPLAIN ANALYZE を確認してから改善する。

参考資料