-- ============================================================================= -- Migration: 20260419000010_documents_security_hardening -- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos -- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes -- pra próxima sessão (precisam de design/decisão). -- -- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}" -- (storage.foldername(name))[1] = tenant_id -- ============================================================================= -- Tabelas de documents são owned por supabase_admin SET LOCAL ROLE supabase_admin; -- ───────────────────────────────────────────────────────────────────────── -- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs" -- ----------------------------------------------------------------------------- DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects; DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects; DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects; DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects; DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects; DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects; CREATE POLICY "documents: tenant member read" ON storage.objects FOR SELECT TO authenticated USING ( bucket_id = 'documents' AND ( public.is_saas_admin() OR (storage.foldername(name))[1]::uuid IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ) ); CREATE POLICY "documents: tenant member upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK ( bucket_id = 'documents' AND (storage.foldername(name))[1]::uuid IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); CREATE POLICY "documents: tenant member delete" ON storage.objects FOR DELETE TO authenticated USING ( bucket_id = 'documents' AND ( public.is_saas_admin() OR (storage.foldername(name))[1]::uuid IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ) ); DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects; DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects; DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects; DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects; DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects; DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects; CREATE POLICY "generated-docs: tenant member read" ON storage.objects FOR SELECT TO authenticated USING ( bucket_id = 'generated-docs' AND ( public.is_saas_admin() OR (storage.foldername(name))[1]::uuid IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ) ); CREATE POLICY "generated-docs: tenant member upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK ( bucket_id = 'generated-docs' AND (storage.foldername(name))[1]::uuid IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); CREATE POLICY "generated-docs: tenant member delete" ON storage.objects FOR DELETE TO authenticated USING ( bucket_id = 'generated-docs' AND ( public.is_saas_admin() OR (storage.foldername(name))[1]::uuid IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ) ); -- ───────────────────────────────────────────────────────────────────────── -- V#45: documents — policies separadas por cmd -- ----------------------------------------------------------------------------- DROP POLICY IF EXISTS "documents: owner full access" ON public.documents; DROP POLICY IF EXISTS "documents: select" ON public.documents; DROP POLICY IF EXISTS "documents: insert" ON public.documents; DROP POLICY IF EXISTS "documents: update" ON public.documents; DROP POLICY IF EXISTS "documents: delete" ON public.documents; -- SELECT: owner OR tenant_member ativo OR saas_admin CREATE POLICY "documents: select" ON public.documents FOR SELECT TO authenticated USING ( owner_id = auth.uid() OR public.is_saas_admin() OR tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); -- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller CREATE POLICY "documents: insert" ON public.documents FOR INSERT TO authenticated WITH CHECK ( owner_id = auth.uid() AND tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); -- UPDATE: só owner CREATE POLICY "documents: update" ON public.documents FOR UPDATE TO authenticated USING (owner_id = auth.uid() OR public.is_saas_admin()) WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin()); -- DELETE: só owner ou saas_admin CREATE POLICY "documents: delete" ON public.documents FOR DELETE TO authenticated USING (owner_id = auth.uid() OR public.is_saas_admin()); -- ───────────────────────────────────────────────────────────────────────── -- V#46: document_share_links — RPC validate_share_token + remover SELECT direto -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE sl document_share_links%ROWTYPE; v_doc documents%ROWTYPE; v_token text; BEGIN v_token := nullif(btrim(coalesce(p_token, '')), ''); IF v_token IS NULL THEN RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023'; END IF; SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1; IF NOT FOUND THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000'; END IF; IF sl.ativo IS NOT TRUE THEN RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000'; END IF; IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000'; END IF; IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000'; END IF; -- Incrementa uso atomicamente UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id; -- Loga acesso (best-effort) BEGIN INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id) SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id FROM documents d WHERE d.id = sl.document_id; EXCEPTION WHEN OTHERS THEN -- não derruba a request se log falhar (schema pode variar) NULL; END; SELECT * INTO v_doc FROM documents WHERE id = sl.document_id; RETURN jsonb_build_object( 'document_id', sl.document_id, 'bucket', v_doc.storage_bucket, 'bucket_path', v_doc.bucket_path, 'nome_original', v_doc.nome_original, 'mime_type', v_doc.mime_type, 'tamanho_bytes', v_doc.tamanho_bytes ); END; $function$; REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated; GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role; -- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário) DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links; DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links; CREATE POLICY "dsl: creator full access" ON public.document_share_links FOR ALL TO authenticated USING (criado_por = auth.uid() OR public.is_saas_admin()) WITH CHECK (criado_por = auth.uid()); -- ───────────────────────────────────────────────────────────────────────── -- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário) -- ----------------------------------------------------------------------------- DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures; DROP POLICY IF EXISTS "ds: select" ON public.document_signatures; DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures; DROP POLICY IF EXISTS "ds: update" ON public.document_signatures; DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures; CREATE POLICY "ds: select" ON public.document_signatures FOR SELECT TO authenticated USING ( public.is_saas_admin() OR tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); -- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller -- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row -- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo) CREATE POLICY "ds: insert" ON public.document_signatures FOR INSERT TO authenticated WITH CHECK ( tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) AND (signatario_id IS NULL OR signatario_id = auth.uid()) ); -- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado') CREATE POLICY "ds: update" ON public.document_signatures FOR UPDATE TO authenticated USING (signatario_id = auth.uid() OR public.is_saas_admin()) WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin()); -- DELETE: signatário, saas_admin ou tenant_admin/owner CREATE POLICY "ds: delete" ON public.document_signatures FOR DELETE TO authenticated USING ( signatario_id = auth.uid() OR public.is_saas_admin() OR tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' AND tm.role IN ('tenant_admin','admin','owner') ) ); -- ───────────────────────────────────────────────────────────────────────── -- V#48: document_access_logs — INSERT com WITH CHECK -- ----------------------------------------------------------------------------- DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs; CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs FOR INSERT TO authenticated WITH CHECK ( tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); -- ───────────────────────────────────────────────────────────────────────── -- V#49: document_templates — INSERT com WITH CHECK -- ----------------------------------------------------------------------------- DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates; DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates; CREATE POLICY "dt: owner can insert" ON public.document_templates FOR INSERT TO authenticated WITH CHECK ( is_global = false AND owner_id = auth.uid() AND tenant_id IN ( SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active' ) ); CREATE POLICY "dt: saas admin can insert global" ON public.document_templates FOR INSERT TO authenticated WITH CHECK (is_global = true AND public.is_saas_admin());