From 934c6202958259f759b32e558859d98a5e456328 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 21 May 2026 04:39:05 -0300 Subject: [PATCH] compliance CFP #7: RPCs de assinatura + service ext + composable Backend foundation pra assinatura eletronica (ROADMAP #1.2 #7). Migration 20260521000006 cria 3 RPCs: - sign_document_by_signature_id (paciente logado, SECURITY INVOKER) - sign_document_by_token (terceiro via share link, SECURITY DEFINER) - get_signable_document_by_token (preview pre-assinatura) IP + user-agent capturados SERVER-SIDE via inet_client_addr() e current_setting('request.headers'). Hash SHA-256 vem do cliente pra integridade. Token via share link incrementa usos no UPDATE. DocumentSignatures.service estendido com 3 wrappers RPC: signByPortal, signByToken, getSignableDocumentByToken. useDocumentSignatures composable novo (Tipo A blueprint) expoe state reativo + acoes: fetchForDocument, requestSignatures, sign, refuse, signWithToken, loadByToken. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../20260521000006_sign_document_rpcs.sql | 251 ++++++++++++++++++ .../composables/useDocumentSignatures.js | 149 +++++++++++ src/services/DocumentSignatures.service.js | 53 ++++ 3 files changed, 453 insertions(+) create mode 100644 database-novo/migrations/20260521000006_sign_document_rpcs.sql create mode 100644 src/features/documents/composables/useDocumentSignatures.js diff --git a/database-novo/migrations/20260521000006_sign_document_rpcs.sql b/database-novo/migrations/20260521000006_sign_document_rpcs.sql new file mode 100644 index 0000000..9be804b --- /dev/null +++ b/database-novo/migrations/20260521000006_sign_document_rpcs.sql @@ -0,0 +1,251 @@ +-- ============================================================================ +-- 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; diff --git a/src/features/documents/composables/useDocumentSignatures.js b/src/features/documents/composables/useDocumentSignatures.js new file mode 100644 index 0000000..21a2c0f --- /dev/null +++ b/src/features/documents/composables/useDocumentSignatures.js @@ -0,0 +1,149 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/documents/composables/useDocumentSignatures.js +| Composable Tipo A (thin wrapper) sobre DocumentSignatures.service. +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { + createSignatureRequests, + listSignatures, + getSignatureStatus, + refuseSignature, + signByPortal, + signByToken, + getSignableDocumentByToken, + hashDocument +} from '@/services/DocumentSignatures.service'; + +export function useDocumentSignatures() { + const signatures = ref([]); + const loading = ref(false); + const error = ref(''); + const status = ref(null); // { total, assinados, pendentes, status } + + async function fetchForDocument(documentoId) { + if (!documentoId) { + signatures.value = []; + status.value = null; + return []; + } + loading.value = true; + error.value = ''; + try { + const [list, st] = await Promise.all([ + listSignatures(documentoId), + getSignatureStatus(documentoId) + ]); + signatures.value = Array.isArray(list) ? list : []; + status.value = st || null; + return signatures.value; + } catch (e) { + error.value = e?.message || 'Falha ao carregar assinaturas.'; + signatures.value = []; + status.value = null; + throw e; + } finally { + loading.value = false; + } + } + + async function requestSignatures(documentoId, signatarios = []) { + loading.value = true; + error.value = ''; + try { + const rows = await createSignatureRequests(documentoId, signatarios); + signatures.value = [...signatures.value, ...rows]; + return rows; + } catch (e) { + error.value = e?.message || 'Falha ao solicitar assinaturas.'; + throw e; + } finally { + loading.value = false; + } + } + + async function sign(signatureId, { hashDocumento = null } = {}) { + loading.value = true; + error.value = ''; + try { + const updated = await signByPortal(signatureId, hashDocumento); + const idx = signatures.value.findIndex(s => s.id === signatureId); + if (idx >= 0) signatures.value.splice(idx, 1, updated); + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao assinar documento.'; + throw e; + } finally { + loading.value = false; + } + } + + async function refuse(signatureId) { + loading.value = true; + error.value = ''; + try { + const updated = await refuseSignature(signatureId); + const idx = signatures.value.findIndex(s => s.id === signatureId); + if (idx >= 0) signatures.value.splice(idx, 1, updated); + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao recusar assinatura.'; + throw e; + } finally { + loading.value = false; + } + } + + async function signWithToken(token, signatureId = null, { hashDocumento = null } = {}) { + loading.value = true; + error.value = ''; + try { + return await signByToken(token, signatureId, hashDocumento); + } catch (e) { + error.value = e?.message || 'Falha ao assinar via link.'; + throw e; + } finally { + loading.value = false; + } + } + + async function loadByToken(token) { + loading.value = true; + error.value = ''; + try { + const payload = await getSignableDocumentByToken(token); + if (!payload?.valid) { + error.value = payload?.error === 'expired_or_invalid' + ? 'Link expirado ou inválido.' + : payload?.error === 'document_not_found' + ? 'Documento não encontrado.' + : 'Token inválido.'; + return null; + } + signatures.value = Array.isArray(payload.signatures) ? payload.signatures : []; + return payload; + } catch (e) { + error.value = e?.message || 'Falha ao validar token.'; + throw e; + } finally { + loading.value = false; + } + } + + return { + signatures, + status, + loading, + error, + fetchForDocument, + requestSignatures, + sign, + refuse, + signWithToken, + loadByToken, + hashDocument + }; +} diff --git a/src/services/DocumentSignatures.service.js b/src/services/DocumentSignatures.service.js index b11b4dd..7b1fce4 100644 --- a/src/services/DocumentSignatures.service.js +++ b/src/services/DocumentSignatures.service.js @@ -170,3 +170,56 @@ export async function refuseSignature(signatureId) { if (error) throw error; return data; } + +// ── Assinar via portal (paciente logado) ───────────────────── +// +// IP e user-agent são capturados SERVER-SIDE pela RPC via +// inet_client_addr() e current_setting('request.headers'). O cliente +// só passa o hash SHA-256 do PDF (gerado via hashDocument()) pra +// garantir integridade do documento no momento da assinatura. +// +export async function signByPortal(signatureId, hashDocumento = null) { + if (!signatureId) throw new Error('ID da assinatura inválido.'); + + const { data, error } = await supabase.rpc('sign_document_by_signature_id', { + p_signature_id: signatureId, + p_hash_documento: hashDocumento || null + }); + if (error) throw error; + return data; +} + +// ── Assinar via token público (share link) ────────────────── +// +// Para signatários não-logados acessando via /shared/document/:token. +// p_signature_id é opcional — quando o documento tem múltiplos +// signatários, identifica qual deles está assinando. Quando há apenas +// um pendente, deixa null e o backend resolve. +// +export async function signByToken(token, signatureId = null, hashDocumento = null) { + if (!token) throw new Error('Token inválido.'); + + const { data, error } = await supabase.rpc('sign_document_by_token', { + p_token: token, + p_signature_id: signatureId || null, + p_hash_documento: hashDocumento || null + }); + if (error) throw error; + return data; +} + +// ── Pré-visualizar documento por token (sem assinar) ──────── +// +// Usado pela página pública pra carregar info do documento + +// signatários pendentes ANTES do click em "Assinar". Retorna +// { valid, document, signatures, expira_em, usos_restantes }. +// +export async function getSignableDocumentByToken(token) { + if (!token) throw new Error('Token inválido.'); + + const { data, error } = await supabase.rpc('get_signable_document_by_token', { + p_token: token + }); + if (error) throw error; + return data; +}