Files
agenciapsilmno/database-novo/migrations/20260521000006_sign_document_rpcs.sql
T
Leonardo 934c620295 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>
2026-05-21 04:39:05 -03:00

252 lines
9.7 KiB
PL/PgSQL

-- ============================================================================
-- 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;