-- supabase/migrations/20260404_create_profiles.sql-- プロフィールテーブルの作成create table public.profiles ( id uuid references auth.users on delete cascade not null primary key, username text unique, full_name text, avatar_url text, subscription_tier text default 'free' check (subscription_tier in ('free', 'pro', 'premium')), created_at timestamptz default now() not null, updated_at timestamptz default now() not null);-- RLS の有効化(必須)alter table public.profiles enable row level security;-- ポリシー:本人のみ参照可能create policy "Users can view own profile" on public.profiles for select using (auth.uid() = id);-- ポリシー:本人のみ更新可能create policy "Users can update own profile" on public.profiles for update using (auth.uid() = id);-- ユーザー作成時に自動でプロフィールを生成するトリガーcreate or replace function public.handle_new_user()returns trigger as $$begin insert into public.profiles (id, full_name, avatar_url) values ( new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url' ); return new;end;$$ language plpgsql security definer;create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user();
-- supabase/migrations/20260404_create_teams.sql-- チームテーブルcreate table public.teams ( id uuid default gen_random_uuid() primary key, name text not null, owner_id uuid references auth.users not null, plan text default 'free' check (plan in ('free', 'pro', 'enterprise')), created_at timestamptz default now() not null);-- チームメンバーテーブル(中間テーブル)create table public.team_members ( team_id uuid references public.teams on delete cascade not null, user_id uuid references auth.users on delete cascade not null, role text default 'member' check (role in ('owner', 'admin', 'member')), joined_at timestamptz default now() not null, primary key (team_id, user_id));-- プロジェクトテーブル(チーム所属)create table public.projects ( id uuid default gen_random_uuid() primary key, team_id uuid references public.teams on delete cascade not null, name text not null, description text, status text default 'active' check (status in ('active', 'archived')), created_at timestamptz default now() not null, updated_at timestamptz default now() not null);-- RLS の有効化alter table public.teams enable row level security;alter table public.team_members enable row level security;alter table public.projects enable row level security;-- ヘルパー関数:チームメンバーかどうかを確認create or replace function public.is_team_member(team_id uuid)returns boolean as $$ select exists ( select 1 from public.team_members where team_members.team_id = $1 and team_members.user_id = auth.uid() )$$ language sql security definer stable;-- ヘルパー関数:チーム管理者かどうかを確認create or replace function public.is_team_admin(team_id uuid)returns boolean as $$ select exists ( select 1 from public.team_members where team_members.team_id = $1 and team_members.user_id = auth.uid() and team_members.role in ('owner', 'admin') )$$ language sql security definer stable;-- teams ポリシーcreate policy "Team members can view their teams" on public.teams for select using (public.is_team_member(id));create policy "Team owners can update team" on public.teams for update using (owner_id = auth.uid());-- projects ポリシーcreate policy "Team members can view projects" on public.projects for select using (public.is_team_member(team_id));create policy "Team admins can create projects" on public.projects for insert with check (public.is_team_admin(team_id));create policy "Team admins can update projects" on public.projects for update using (public.is_team_admin(team_id));
RLS と TypeScript の型安全な連携
// lib/supabase/queries/projects.tsimport { createClient } from '@/lib/supabase/server'import type { Database } from '@/types/supabase'type Project = Database['public']['Tables']['projects']['Row']export async function getProjectsByTeam(teamId: string): Promise<Project[]> { const supabase = await createClient() const { data, error } = await supabase .from('projects') .select(` *, team:teams(name, plan) `) .eq('team_id', teamId) .eq('status', 'active') .order('created_at', { ascending: false }) if (error) { console.error('Failed to fetch projects:', error) throw new Error(error.message) } // RLS により、このクエリは自動的に認証済みユーザーが // アクセス権を持つプロジェクトのみを返す return data}
-- storage バケットの RLS ポリシー(Supabase Studio または マイグレーションで設定)-- ユーザーは自分のファイルのみアップロード可能create policy "Users can upload own files" on storage.objects for insert to authenticated with check ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = auth.uid()::text );-- ユーザーは自分のファイルのみ参照可能create policy "Users can view own files" on storage.objects for select to authenticated using ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = auth.uid()::text );-- ユーザーは自分のファイルのみ削除可能create policy "Users can delete own files" on storage.objects for delete to authenticated using ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = auth.uid()::text );
-- 改善前create policy "own projects" on public.projects for select using (auth.uid() = user_id);-- 改善後:InitPlan 化して 1 回だけ評価させるdrop policy "own projects" on public.projects;create policy "own projects" on public.projects for select using ((select auth.uid()) = user_id);
二段目のインデックスは当たり前に見えますが、見落としがちな点があります。ポリシーの using 句に出てくる列は、アプリのクエリに書いていなくても実質的な検索条件です。user_id や team_id のように「ポリシーだけが参照する列」にこそ、インデックスが要ります。
マルチテナントで無限再帰させないための security definer
三段目は、ポリシーの中で別テーブルを引くときの話です。team_members を参照して所属を確かめるポリシーを team_members 自身にも書くと、ポリシー評価がポリシー評価を呼び、infinite recursion detected in policy で落ちます。
回避策は、参照側を RLS の外に出すことです。
-- 所属判定を security definer 関数に閉じ込める-- (関数内のクエリは呼び出し元の RLS を受けないため再帰しない)create or replace function public.is_team_member(target_team uuid)returns booleanlanguage sqlsecurity definerset search_path = public -- search_path 固定は必須。省くと権限昇格の穴になるstableas $$ select exists ( select 1 from public.team_members where team_id = target_team and user_id = (select auth.uid()) );$$;revoke all on function public.is_team_member(uuid) from public;grant execute on function public.is_team_member(uuid) to authenticated;create policy "team projects" on public.projects for select using (public.is_team_member(team_id));
security definer は、うかつに使えば権限を素通しにしてしまう機能です。だからこそ set search_path = public を必ず添え、revoke all してから authenticated にだけ execute を戻します。この 2 行を省いたテンプレートを見かけますが、省いた瞬間に RLS を書いた意味が薄れます。