Files
root-org/supabase/migrations/039_security_performance_fixes.sql

369 lines
13 KiB
PL/PgSQL

-- ============================================================
-- 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.
-- ============================================================