-- Migration 003: RLS policies + is_active_member helper -- All business access rules live here. Every table has RLS enabled in its own -- migration; this file only adds the policies. -- ── Helper: is_active_member ────────────────────────────────────────────────── -- Returns true if the calling user is a non-guest member of the collective. -- Used by all content-table policies. Declared STABLE so PostgreSQL can cache -- the result within a single statement. CREATE OR REPLACE FUNCTION public.is_active_member(p_collective_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT EXISTS ( SELECT 1 FROM public.collective_members WHERE collective_id = p_collective_id AND user_id = auth.uid() AND role IN ('admin', 'member') ); $$; -- Variant that also accepts guests (read access). CREATE OR REPLACE FUNCTION public.is_member(p_collective_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT EXISTS ( SELECT 1 FROM public.collective_members WHERE collective_id = p_collective_id AND user_id = auth.uid() ); $$; CREATE OR REPLACE FUNCTION public.is_admin(p_collective_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT EXISTS ( SELECT 1 FROM public.collective_members WHERE collective_id = p_collective_id AND user_id = auth.uid() AND role = 'admin' ); $$; -- ── public.users policies ───────────────────────────────────────────────────── -- Any authenticated user can read their own row. CREATE POLICY users_select_own ON public.users FOR SELECT USING (id = auth.uid()); -- Members can read profiles of users in the same collective. CREATE POLICY users_select_collective_peers ON public.users FOR SELECT USING ( EXISTS ( SELECT 1 FROM public.collective_members cm1 JOIN public.collective_members cm2 ON cm2.collective_id = cm1.collective_id AND cm2.user_id = auth.uid() WHERE cm1.user_id = public.users.id ) ); -- A user can only update their own row. CREATE POLICY users_update_own ON public.users FOR UPDATE USING (id = auth.uid()) WITH CHECK (id = auth.uid()); -- Insert is handled by the trigger (SECURITY DEFINER). No direct INSERT allowed. CREATE POLICY users_insert_deny ON public.users FOR INSERT WITH CHECK (false); -- Delete cascades from auth.users deletion; no direct DELETE allowed here. CREATE POLICY users_delete_deny ON public.users FOR DELETE USING (false); -- ── public.collectives policies ─────────────────────────────────────────────── -- Members (any role) can read collectives they belong to. CREATE POLICY collectives_select ON public.collectives FOR SELECT USING (public.is_member(id)); -- Any authenticated user can create a collective (they become its first admin). CREATE POLICY collectives_insert ON public.collectives FOR INSERT WITH CHECK (created_by = auth.uid()); -- Only admins can update the collective name / emoji. CREATE POLICY collectives_update ON public.collectives FOR UPDATE USING (public.is_admin(id)) WITH CHECK (public.is_admin(id)); -- Admins can delete a collective. CREATE POLICY collectives_delete ON public.collectives FOR DELETE USING (public.is_admin(id)); -- ── public.collective_members policies ─────────────────────────────────────── -- Members of a collective can read its member list. CREATE POLICY collective_members_select ON public.collective_members FOR SELECT USING (public.is_member(collective_id)); -- Only admins can add members (accepting invitations handled via server action -- with SECURITY DEFINER function, not direct INSERT). CREATE POLICY collective_members_insert ON public.collective_members FOR INSERT WITH CHECK (public.is_admin(collective_id)); -- Only admins can change roles; users cannot promote themselves. CREATE POLICY collective_members_update ON public.collective_members FOR UPDATE USING (public.is_admin(collective_id)) WITH CHECK (public.is_admin(collective_id)); -- Admins can remove any member; members can remove themselves. CREATE POLICY collective_members_delete ON public.collective_members FOR DELETE USING ( public.is_admin(collective_id) OR user_id = auth.uid() ); -- ── public.collective_invitations policies ──────────────────────────────────── -- Members of a collective can read its invitations (to copy the link). CREATE POLICY collective_invitations_select ON public.collective_invitations FOR SELECT USING (public.is_member(collective_id)); -- Only admins can create invitations. CREATE POLICY collective_invitations_insert ON public.collective_invitations FOR INSERT WITH CHECK ( public.is_admin(collective_id) AND created_by = auth.uid() ); -- Only admins can revoke (delete) invitations. CREATE POLICY collective_invitations_delete ON public.collective_invitations FOR DELETE USING (public.is_admin(collective_id)); -- Accepting an invitation (setting accepted_at) is done via a SECURITY DEFINER -- server action — no UPDATE policy needed here. CREATE POLICY collective_invitations_update_deny ON public.collective_invitations FOR UPDATE USING (false); -- ── SECURITY DEFINER: accept invitation ────────────────────────────────────── -- Called from the SvelteKit server action for /invitation/[token]. -- Validates token, expiry, and that user isn't already a member, then inserts -- into collective_members and stamps accepted_at. CREATE OR REPLACE FUNCTION public.accept_invitation(p_token uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_invitation public.collective_invitations%ROWTYPE; v_user_id uuid := auth.uid(); BEGIN IF v_user_id IS NULL THEN RETURN jsonb_build_object('error', 'unauthenticated'); END IF; SELECT * INTO v_invitation FROM public.collective_invitations WHERE token = p_token FOR UPDATE; IF NOT FOUND THEN RETURN jsonb_build_object('error', 'not_found'); END IF; IF v_invitation.accepted_at IS NOT NULL THEN RETURN jsonb_build_object('error', 'already_used'); END IF; IF v_invitation.expires_at < now() THEN RETURN jsonb_build_object('error', 'expired'); END IF; IF EXISTS ( SELECT 1 FROM public.collective_members WHERE collective_id = v_invitation.collective_id AND user_id = v_user_id ) THEN RETURN jsonb_build_object('error', 'already_member'); END IF; INSERT INTO public.collective_members (collective_id, user_id, role) VALUES (v_invitation.collective_id, v_user_id, v_invitation.role); UPDATE public.collective_invitations SET accepted_at = now(), accepted_by = v_user_id WHERE id = v_invitation.id; RETURN jsonb_build_object( 'ok', true, 'collective_id', v_invitation.collective_id, 'role', v_invitation.role ); END; $$;