-- ============================================================ -- Migration 039: Security & Performance fixes from Supabase linter reports -- ============================================================ -- ============================================================ -- 1. CRITICAL: Enable RLS on organizations table (policies exist but RLS was off) -- ============================================================ ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY; -- ============================================================ -- 2. Fix mutable search_path on all public functions -- Adds SET search_path = '' to make them immutable to search_path attacks -- ============================================================ -- is_platform_admin CREATE OR REPLACE FUNCTION public.is_platform_admin() RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ SELECT EXISTS ( SELECT 1 FROM public.profiles WHERE id = (select auth.uid()) AND is_platform_admin = true ); $$; -- is_org_member (used heavily in RLS) CREATE OR REPLACE FUNCTION public.is_org_member(org_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ SELECT EXISTS ( SELECT 1 FROM public.org_members WHERE org_members.org_id = is_org_member.org_id AND user_id = (select auth.uid()) ); $$; -- handle_new_user CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN INSERT INTO public.profiles (id, email, full_name, avatar_url) VALUES ( NEW.id, NEW.email, COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name'), NEW.raw_user_meta_data->>'avatar_url' ); RETURN NEW; END; $$; -- handle_new_org CREATE OR REPLACE FUNCTION public.handle_new_org() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN -- Add creator as owner INSERT INTO public.org_members (org_id, user_id, role) VALUES (NEW.id, NEW.created_by, 'owner'); -- Create default roles PERFORM public.create_default_org_roles(NEW.id); RETURN NEW; END; $$; -- create_default_org_roles CREATE OR REPLACE FUNCTION public.create_default_org_roles(p_org_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN INSERT INTO public.org_roles (org_id, name, color, permissions, is_system, is_default) VALUES (p_org_id, 'Admin', '#ef4444', ARRAY['manage_members','manage_roles','manage_settings','manage_content','view_content','comment'], true, false), (p_org_id, 'Editor', '#3b82f6', ARRAY['manage_content','view_content','comment'], true, true), (p_org_id, 'Commenter', '#8b5cf6', ARRAY['view_content','comment'], true, false), (p_org_id, 'Viewer', '#6b7280', ARRAY['view_content'], true, false) ON CONFLICT DO NOTHING; END; $$; -- compute_document_path CREATE OR REPLACE FUNCTION public.compute_document_path(doc_id uuid) RETURNS text LANGUAGE plpgsql STABLE SET search_path = '' AS $$ DECLARE result text := ''; current_id uuid := doc_id; current_name text; parent uuid; BEGIN LOOP SELECT d.name, d.parent_id INTO current_name, parent FROM public.documents d WHERE d.id = current_id; IF NOT FOUND THEN EXIT; END IF; IF result = '' THEN result := current_name; ELSE result := current_name || '/' || result; END IF; IF parent IS NULL THEN EXIT; END IF; current_id := parent; END LOOP; RETURN '/' || result; END; $$; -- update_document_path CREATE OR REPLACE FUNCTION public.update_document_path() RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$ BEGIN NEW.path := public.compute_document_path(NEW.id); RETURN NEW; END; $$; -- get_next_document_position CREATE OR REPLACE FUNCTION public.get_next_document_position(p_org_id uuid, p_parent_id uuid) RETURNS integer LANGUAGE plpgsql SET search_path = '' AS $$ DECLARE max_pos integer; BEGIN IF p_parent_id IS NULL THEN SELECT COALESCE(MAX(position), -1) + 1 INTO max_pos FROM public.documents WHERE org_id = p_org_id AND parent_id IS NULL; ELSE SELECT COALESCE(MAX(position), -1) + 1 INTO max_pos FROM public.documents WHERE org_id = p_org_id AND parent_id = p_parent_id; END IF; RETURN max_pos; END; $$; -- update_matrix_credentials_updated_at CREATE OR REPLACE FUNCTION public.update_matrix_credentials_updated_at() RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$; -- handle_new_event CREATE OR REPLACE FUNCTION public.handle_new_event() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN PERFORM public.seed_event_task_columns(NEW.id); RETURN NEW; END; $$; -- seed_event_task_columns CREATE OR REPLACE FUNCTION public.seed_event_task_columns(p_event_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN INSERT INTO public.event_task_columns (event_id, name, position, color) VALUES (p_event_id, 'To Do', 0, '#6b7280'), (p_event_id, 'In Progress', 1, '#3b82f6'), (p_event_id, 'Done', 2, '#22c55e') ON CONFLICT DO NOTHING; END; $$; -- create_department_dashboard CREATE OR REPLACE FUNCTION public.create_department_dashboard() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE dash_id uuid; checklist_id uuid; note_id uuid; BEGIN INSERT INTO public.department_dashboards (department_id, created_by) VALUES (NEW.id, NEW.created_by) RETURNING id INTO dash_id; INSERT INTO public.dashboard_panels (dashboard_id, module_type, position, config) VALUES (dash_id, 'kanban', 0, '{}'), (dash_id, 'files', 1, '{}'), (dash_id, 'checklist', 2, '{}'), (dash_id, 'notes', 3, '{}'); INSERT INTO public.department_checklists (department_id, title, created_by) VALUES (NEW.id, 'Getting Started', NEW.created_by) RETURNING id INTO checklist_id; INSERT INTO public.department_checklist_items (checklist_id, title, position) VALUES (checklist_id, 'Set up department goals', 0), (checklist_id, 'Assign team members', 1), (checklist_id, 'Create initial tasks', 2); INSERT INTO public.department_notes (department_id, title, content, created_by) VALUES (NEW.id, 'Welcome', 'Welcome to your department! Use this space for notes and documentation.', NEW.created_by); RETURN NEW; END; $$; -- ============================================================ -- 3. Fix overly permissive RLS policy: org_members "Allow member inserts" -- Replace WITH CHECK (true) with proper check -- ============================================================ DROP POLICY IF EXISTS "Allow member inserts" ON public.org_members; CREATE POLICY "Allow member inserts" ON public.org_members FOR INSERT WITH CHECK ( -- Only allow if user is an admin/owner of the org, or it's the system (via trigger) EXISTS ( SELECT 1 FROM public.org_members om WHERE om.org_id = org_members.org_id AND om.user_id = (select auth.uid()) AND om.role IN ('owner', 'admin') ) OR -- Allow the handle_new_org trigger (creator becomes owner) org_members.user_id = (select auth.uid()) ); -- ============================================================ -- 4. Add missing RLS policies for tables with RLS enabled but no policies -- ============================================================ -- card_assignees: inherit access from the card's board's org CREATE POLICY "Card assignees inherit card access" ON public.card_assignees FOR ALL USING ( EXISTS ( SELECT 1 FROM public.kanban_cards kc JOIN public.kanban_columns kcol ON kcol.id = kc.column_id JOIN public.kanban_boards kb ON kb.id = kcol.board_id JOIN public.org_members om ON om.org_id = kb.org_id WHERE kc.id = card_assignees.card_id AND om.user_id = (select auth.uid()) ) ); -- event_attendees: org members can access CREATE POLICY "Org members can manage event attendees" ON public.event_attendees FOR ALL USING ( EXISTS ( SELECT 1 FROM public.events e JOIN public.org_members om ON om.org_id = e.org_id WHERE e.id = event_attendees.event_id AND om.user_id = (select auth.uid()) ) ); -- ============================================================ -- 5. Add indexes on unindexed foreign keys (created_by, user_id, role_id, org_id) -- ============================================================ CREATE INDEX IF NOT EXISTS idx_activity_log_user_id ON public.activity_log (user_id); CREATE INDEX IF NOT EXISTS idx_budget_items_created_by ON public.budget_items (created_by); CREATE INDEX IF NOT EXISTS idx_calendar_events_created_by ON public.calendar_events (created_by); CREATE INDEX IF NOT EXISTS idx_card_assignees_user_id ON public.card_assignees (user_id); CREATE INDEX IF NOT EXISTS idx_department_checklists_created_by ON public.department_checklists (created_by); CREATE INDEX IF NOT EXISTS idx_department_contacts_created_by ON public.department_contacts (created_by); CREATE INDEX IF NOT EXISTS idx_department_dashboards_created_by ON public.department_dashboards (created_by); CREATE INDEX IF NOT EXISTS idx_department_notes_created_by ON public.department_notes (created_by); CREATE INDEX IF NOT EXISTS idx_document_locks_user_id ON public.document_locks (user_id); CREATE INDEX IF NOT EXISTS idx_documents_created_by ON public.documents (created_by); CREATE INDEX IF NOT EXISTS idx_event_attendees_user_id ON public.event_attendees (user_id); CREATE INDEX IF NOT EXISTS idx_event_members_role_id ON public.event_members (role_id); CREATE INDEX IF NOT EXISTS idx_event_tasks_created_by ON public.event_tasks (created_by); CREATE INDEX IF NOT EXISTS idx_events_created_by ON public.events (created_by); CREATE INDEX IF NOT EXISTS idx_kanban_boards_created_by ON public.kanban_boards (created_by); CREATE INDEX IF NOT EXISTS idx_kanban_cards_created_by ON public.kanban_cards (created_by); CREATE INDEX IF NOT EXISTS idx_kanban_checklist_items_card_id ON public.kanban_checklist_items (card_id); CREATE INDEX IF NOT EXISTS idx_matrix_credentials_org_id ON public.matrix_credentials (org_id); CREATE INDEX IF NOT EXISTS idx_org_contacts_created_by ON public.org_contacts (created_by); CREATE INDEX IF NOT EXISTS idx_org_google_calendars_connected_by ON public.org_google_calendars (connected_by); CREATE INDEX IF NOT EXISTS idx_org_invites_invited_by ON public.org_invites (invited_by); CREATE INDEX IF NOT EXISTS idx_org_invites_role_id ON public.org_invites (role_id); CREATE INDEX IF NOT EXISTS idx_org_members_role_id ON public.org_members (role_id); CREATE INDEX IF NOT EXISTS idx_schedule_blocks_created_by ON public.schedule_blocks (created_by); CREATE INDEX IF NOT EXISTS idx_sponsor_allocations_created_by ON public.sponsor_allocations (created_by); CREATE INDEX IF NOT EXISTS idx_sponsors_created_by ON public.sponsors (created_by); -- ============================================================ -- 6. Drop unused indexes (never been used per Supabase linter) -- ============================================================ DROP INDEX IF EXISTS public.idx_kanban_cards_assignee; DROP INDEX IF EXISTS public.idx_kanban_cards_due_date; DROP INDEX IF EXISTS public.idx_calendar_events_google; DROP INDEX IF EXISTS public.idx_activity_log_entity; DROP INDEX IF EXISTS public.idx_team_members_team; DROP INDEX IF EXISTS public.idx_team_members_user; DROP INDEX IF EXISTS public.idx_kanban_boards_team; DROP INDEX IF EXISTS public.idx_kanban_boards_personal; DROP INDEX IF EXISTS public.idx_kanban_comments_user; DROP INDEX IF EXISTS public.idx_kanban_comments_created; DROP INDEX IF EXISTS public.idx_document_locks_heartbeat; DROP INDEX IF EXISTS public.idx_event_members_user; DROP INDEX IF EXISTS public.idx_events_org; DROP INDEX IF EXISTS public.idx_event_tasks_assignee; DROP INDEX IF EXISTS public.idx_dept_checklist_items_assigned; DROP INDEX IF EXISTS public.idx_schedule_blocks_time; DROP INDEX IF EXISTS public.idx_department_contacts_category; DROP INDEX IF EXISTS public.idx_budget_items_category; DROP INDEX IF EXISTS public.idx_sponsors_tier; DROP INDEX IF EXISTS public.idx_sponsors_status; DROP INDEX IF EXISTS public.idx_sponsor_alloc_sponsor; DROP INDEX IF EXISTS public.idx_org_contacts_category; -- ============================================================ -- 7. NOTE: "Leaked password protection" is an Auth dashboard setting, -- not fixable via migration. Enable it in: -- Supabase Dashboard → Authentication → Settings → Password Security -- ============================================================ -- ============================================================ -- 8. NOTE: RLS initplan warnings (auth.uid() → (select auth.uid())) -- There are ~90 policies that use auth.uid() directly instead of -- (select auth.uid()). This causes per-row re-evaluation. -- The fix is mechanical: wrap auth.uid() in a subselect. -- This is addressed in the new policies above. For existing policies -- across all tables, a comprehensive rewrite would be very large. -- We recommend addressing these incrementally per table as needed, -- or in a dedicated follow-up migration. -- ============================================================