369 lines
13 KiB
PL/PgSQL
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.
|
|
-- ============================================================
|