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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -170,3 +170,56 @@ export async function refuseSignature(signatureId) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user