-- ============================================================================ -- Compliance CFP #7 — RPCs de assinatura eletrônica -- ---------------------------------------------------------------------------- -- Cria 2 RPCs que registram assinatura capturando IP server-side (anti-spoof) -- via inet_client_addr() e user-agent via request headers do Supabase. -- -- • sign_document_by_signature_id — paciente logado assina via portal -- • sign_document_by_token — terceiro assina via share link público -- -- ROADMAP item #1.2 #7 (Assinatura eletrônica pelo paciente no portal, -- simples, com IP+timestamp). Não usa ICP-Brasil — é assinatura simples -- com audit trail (IP, UA, timestamp, hash SHA-256 do documento). -- ============================================================================ BEGIN; -- ────────────────────────────────────────────────────────────────────────── -- 1. sign_document_by_signature_id -- ---------------------------------------------------------------------------- -- Para signatários LOGADOS no portal/sistema. SECURITY INVOKER — a RLS de -- document_signatures continua aplicando (signatario_id = auth.uid() ou -- tenant_members). RPC só serve pra centralizar captura de IP + UA + hash. -- ────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.sign_document_by_signature_id( p_signature_id uuid, p_hash_documento text DEFAULT NULL ) RETURNS public.document_signatures LANGUAGE plpgsql SECURITY INVOKER AS $$ DECLARE v_row public.document_signatures; v_ip inet; v_ua text; BEGIN IF p_signature_id IS NULL THEN RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE = '22023'; END IF; -- Captura IP e UA do request (best-effort — pode vir NULL em alguns ambientes) v_ip := inet_client_addr(); BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END; UPDATE public.document_signatures SET status = 'assinado', ip = v_ip, user_agent = v_ua, assinado_em = now(), hash_documento = COALESCE(p_hash_documento, hash_documento), atualizado_em = now() WHERE id = p_signature_id AND status IN ('pendente', 'enviado') RETURNING * INTO v_row; IF v_row.id IS NULL THEN RAISE EXCEPTION 'Assinatura não encontrada ou já processada' USING ERRCODE = 'P0002'; END IF; RETURN v_row; END; $$; COMMENT ON FUNCTION public.sign_document_by_signature_id(uuid, text) IS 'Assinatura via portal logado. Captura IP/UA server-side. RLS aplica (SECURITY INVOKER).'; GRANT EXECUTE ON FUNCTION public.sign_document_by_signature_id(uuid, text) TO authenticated; -- ────────────────────────────────────────────────────────────────────────── -- 2. sign_document_by_token -- ---------------------------------------------------------------------------- -- Para signatários NÃO LOGADOS via share link público. SECURITY DEFINER — -- bypassa RLS. Valida o share_link (token, ativo, expira_em, usos_max), -- localiza o signatário PENDENTE associado ao documento (signatario_email -- opcional p/ desambiguar quando há múltiplos), assina, incrementa usos. -- ────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.sign_document_by_token( p_token text, p_signature_id uuid DEFAULT NULL, p_hash_documento text DEFAULT NULL ) RETURNS public.document_signatures LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_link public.document_share_links; v_sig public.document_signatures; v_ip inet; v_ua text; BEGIN IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023'; END IF; -- Valida share_link SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo = true AND expira_em > now() AND usos < usos_max LIMIT 1; IF v_link.id IS NULL THEN RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE = 'P0002'; END IF; -- Localiza a signature pendente do documento. Se p_signature_id veio, -- é desambiguação (multi-signatário); senão pega a primeira pendente -- por ordem. IF p_signature_id IS NOT NULL THEN SELECT * INTO v_sig FROM public.document_signatures WHERE id = p_signature_id AND documento_id = v_link.documento_id AND status IN ('pendente', 'enviado') LIMIT 1; ELSE SELECT * INTO v_sig FROM public.document_signatures WHERE documento_id = v_link.documento_id AND status IN ('pendente', 'enviado') ORDER BY ordem ASC, criado_em ASC LIMIT 1; END IF; IF v_sig.id IS NULL THEN RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE = 'P0002'; END IF; -- Captura IP/UA v_ip := inet_client_addr(); BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END; -- Assina UPDATE public.document_signatures SET status = 'assinado', ip = v_ip, user_agent = v_ua, assinado_em = now(), hash_documento = COALESCE(p_hash_documento, hash_documento), atualizado_em = now() WHERE id = v_sig.id RETURNING * INTO v_sig; -- Incrementa contador de usos do share_link UPDATE public.document_share_links SET usos = usos + 1 WHERE id = v_link.id; RETURN v_sig; END; $$; COMMENT ON FUNCTION public.sign_document_by_token(text, uuid, text) IS 'Assinatura via share link público. SECURITY DEFINER — valida token, captura IP/UA, incrementa usos. p_signature_id é opcional pra desambiguar multi-signatário.'; GRANT EXECUTE ON FUNCTION public.sign_document_by_token(text, uuid, text) TO anon, authenticated; -- ────────────────────────────────────────────────────────────────────────── -- 3. get_signable_document_by_token -- ---------------------------------------------------------------------------- -- View helper que retorna info do documento + signatários pendentes via token, -- sem assinar. Permite a página pública renderizar antes do click. -- SECURITY DEFINER porque share_link tem RLS pública mas documents+signatures -- têm RLS por owner/tenant. -- ────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.get_signable_document_by_token( p_token text ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_link public.document_share_links; v_doc public.documents; v_sigs jsonb; BEGIN IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023'; END IF; SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo = true AND expira_em > now() AND usos < usos_max LIMIT 1; IF v_link.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid'); END IF; SELECT * INTO v_doc FROM public.documents WHERE id = v_link.documento_id AND deleted_at IS NULL LIMIT 1; IF v_doc.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'document_not_found'); END IF; SELECT jsonb_agg( jsonb_build_object( 'id', s.id, 'signatario_tipo', s.signatario_tipo, 'signatario_nome', s.signatario_nome, 'signatario_email', s.signatario_email, 'ordem', s.ordem, 'status', s.status, 'assinado_em', s.assinado_em ) ORDER BY s.ordem ) INTO v_sigs FROM public.document_signatures s WHERE s.documento_id = v_doc.id; RETURN jsonb_build_object( 'valid', true, 'document', jsonb_build_object( 'id', v_doc.id, 'nome_original', v_doc.nome_original, 'mime_type', v_doc.mime_type, 'tamanho_bytes', v_doc.tamanho_bytes, 'bucket_path', v_doc.bucket_path, 'storage_bucket', v_doc.storage_bucket, 'tipo_documento', v_doc.tipo_documento ), 'signatures', COALESCE(v_sigs, '[]'::jsonb), 'expira_em', v_link.expira_em, 'usos_restantes', v_link.usos_max - v_link.usos ); END; $$; COMMENT ON FUNCTION public.get_signable_document_by_token(text) IS 'Retorna documento + signatários pendentes via token. Usado pela página pública antes de assinar.'; GRANT EXECUTE ON FUNCTION public.get_signable_document_by_token(text) TO anon, authenticated; COMMIT;