7c20b518d4
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
281 lines
14 KiB
PL/PgSQL
281 lines
14 KiB
PL/PgSQL
-- =============================================================================
|
|
-- Migration: 20260418000003_patient_invite_attempts_log
|
|
-- Resolve A#24: log de tentativas de submit no cadastro público externo.
|
|
-- -----------------------------------------------------------------------------
|
|
-- Observação sobre IP: em RPC Postgres chamada via PostgREST o IP real do
|
|
-- cliente não chega aqui (só o do connection pooler). Por isso o registro
|
|
-- guarda o user_agent enviado pelo cliente (quando disponível) + metadados
|
|
-- resolvidos (owner, tenant). Rate-limit real por IP deve ser feito em edge
|
|
-- function no futuro (A#20).
|
|
-- =============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS public.patient_invite_attempts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
token text NOT NULL,
|
|
ok boolean NOT NULL,
|
|
error_code text,
|
|
error_msg text,
|
|
client_info text, -- user_agent enviado pelo cliente (cap 500 no INSERT)
|
|
owner_id uuid, -- resolvido do token quando possível
|
|
tenant_id uuid,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_created ON public.patient_invite_attempts(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_token ON public.patient_invite_attempts(token);
|
|
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_owner ON public.patient_invite_attempts(owner_id);
|
|
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_ok ON public.patient_invite_attempts(ok) WHERE ok = false;
|
|
|
|
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Owner vê suas próprias tentativas (qualquer flood/erro que envolveu seus links)
|
|
DROP POLICY IF EXISTS patient_invite_attempts_owner_read ON public.patient_invite_attempts;
|
|
CREATE POLICY patient_invite_attempts_owner_read
|
|
ON public.patient_invite_attempts FOR SELECT
|
|
TO authenticated
|
|
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
|
|
|
COMMENT ON TABLE public.patient_invite_attempts IS
|
|
'Log de tentativas (ok e falhas) de submit do form público de cadastro externo. Base para monitoramento de flood/tentativas maliciosas. Sem IP direto — proteção LGPD.';
|
|
|
|
COMMENT ON COLUMN public.patient_invite_attempts.client_info IS
|
|
'User-agent enviado pelo cliente (opcional). Limitado a 500 chars no insert. Não contém PII.';
|
|
|
|
-- =============================================================================
|
|
-- create_patient_intake_request_v2 — versão instrumentada
|
|
-- -----------------------------------------------------------------------------
|
|
-- Mesma função do hardening anterior, agora com log em patient_invite_attempts.
|
|
-- O log é feito num bloco EXCEPTION que NUNCA propaga falha de log pro fluxo
|
|
-- principal (log falhar jamais deve impedir o cadastro de ser aceito).
|
|
-- =============================================================================
|
|
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
|
|
p_token text,
|
|
p_payload jsonb,
|
|
p_client_info text DEFAULT NULL
|
|
)
|
|
RETURNS uuid
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path TO 'public'
|
|
AS $function$
|
|
DECLARE
|
|
v_owner_id uuid;
|
|
v_tenant_id uuid;
|
|
v_active boolean;
|
|
v_expires timestamptz;
|
|
v_max_uses int;
|
|
v_uses int;
|
|
v_intake_id uuid;
|
|
v_birth_raw text;
|
|
v_birth date;
|
|
v_email text;
|
|
v_email_alt text;
|
|
v_nome text;
|
|
v_consent boolean;
|
|
v_genero text;
|
|
v_estado_civil text;
|
|
v_err_msg text;
|
|
v_err_code text;
|
|
v_clean_info text;
|
|
|
|
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
|
|
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
|
|
|
|
-- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
|
|
-- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
|
|
BEGIN
|
|
-- Sanitiza client_info recebido (cap + trim)
|
|
v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');
|
|
|
|
-- ───────────────────────────────────────────────────────────────────────
|
|
-- Resolve invite + valida TUDO (A#16)
|
|
-- ───────────────────────────────────────────────────────────────────────
|
|
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
|
|
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
|
|
FROM public.patient_invites
|
|
WHERE token = p_token
|
|
LIMIT 1;
|
|
|
|
IF v_owner_id IS NULL THEN
|
|
v_err_code := 'TOKEN_INVALID';
|
|
v_err_msg := 'Token inválido';
|
|
-- Log + raise (owner_id NULL porque token não bateu)
|
|
BEGIN
|
|
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
|
|
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
|
|
EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
|
END IF;
|
|
|
|
IF v_active IS NOT TRUE THEN
|
|
v_err_code := 'TOKEN_DISABLED';
|
|
v_err_msg := 'Link desativado';
|
|
BEGIN
|
|
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
|
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
|
EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
|
END IF;
|
|
|
|
IF v_expires IS NOT NULL AND now() > v_expires THEN
|
|
v_err_code := 'TOKEN_EXPIRED';
|
|
v_err_msg := 'Link expirado';
|
|
BEGIN
|
|
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
|
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
|
EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
|
END IF;
|
|
|
|
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
|
|
v_err_code := 'TOKEN_MAX_USES';
|
|
v_err_msg := 'Limite de uso atingido';
|
|
BEGIN
|
|
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
|
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
|
EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
|
END IF;
|
|
|
|
-- Resolve tenant_id se invite não tiver (A#19)
|
|
IF v_tenant_id IS NULL THEN
|
|
SELECT tenant_id
|
|
INTO v_tenant_id
|
|
FROM public.tenant_members
|
|
WHERE user_id = v_owner_id
|
|
AND status = 'active'
|
|
ORDER BY created_at ASC
|
|
LIMIT 1;
|
|
END IF;
|
|
|
|
-- ───────────────────────────────────────────────────────────────────────
|
|
-- Sanitização + validações de campos (A#27)
|
|
-- ───────────────────────────────────────────────────────────────────────
|
|
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
|
|
IF v_nome IS NULL THEN
|
|
v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
IF length(v_nome) > 200 THEN
|
|
v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
|
|
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
|
|
IF v_email IS NULL THEN
|
|
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
IF length(v_email) > 120 THEN
|
|
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
|
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
|
|
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
|
|
IF v_email_alt IS NOT NULL THEN
|
|
IF length(v_email_alt) > 120 THEN
|
|
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
|
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
END IF;
|
|
|
|
v_consent := coalesce((p_payload->>'consent')::boolean, false);
|
|
IF v_consent IS NOT TRUE THEN
|
|
v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
|
|
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
RAISE EXCEPTION '%', v_err_msg;
|
|
END IF;
|
|
|
|
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
|
|
v_birth := CASE
|
|
WHEN v_birth_raw IS NULL THEN NULL
|
|
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
|
|
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
|
|
ELSE NULL
|
|
END;
|
|
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
|
|
v_birth := NULL;
|
|
END IF;
|
|
|
|
v_genero := nullif(trim(p_payload->>'genero'), '');
|
|
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
|
|
v_genero := NULL;
|
|
END IF;
|
|
|
|
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
|
|
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
|
|
v_estado_civil := NULL;
|
|
END IF;
|
|
|
|
-- ───────────────────────────────────────────────────────────────────────
|
|
-- INSERT
|
|
-- ───────────────────────────────────────────────────────────────────────
|
|
INSERT INTO public.patient_intake_requests (
|
|
owner_id, tenant_id, token, status, consent,
|
|
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
|
avatar_url,
|
|
data_nascimento, cpf, rg, genero, estado_civil,
|
|
profissao, escolaridade, nacionalidade, naturalidade,
|
|
cep, pais, cidade, estado, endereco, numero, complemento, bairro,
|
|
observacoes, encaminhado_por, onde_nos_conheceu
|
|
)
|
|
VALUES (
|
|
v_owner_id, v_tenant_id, p_token, 'new', v_consent,
|
|
v_nome, v_email, v_email_alt,
|
|
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
|
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
|
|
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
|
|
v_birth,
|
|
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
|
left(nullif(trim(p_payload->>'rg'), ''), 20),
|
|
v_genero, v_estado_civil,
|
|
left(nullif(trim(p_payload->>'profissao'), ''), 120),
|
|
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
|
|
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
|
|
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
|
|
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
|
left(nullif(trim(p_payload->>'pais'), ''), 60),
|
|
left(nullif(trim(p_payload->>'cidade'), ''), 120),
|
|
left(nullif(trim(p_payload->>'estado'), ''), 2),
|
|
left(nullif(trim(p_payload->>'endereco'), ''), 200),
|
|
left(nullif(trim(p_payload->>'numero'), ''), 20),
|
|
left(nullif(trim(p_payload->>'complemento'), ''), 120),
|
|
left(nullif(trim(p_payload->>'bairro'), ''), 120),
|
|
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
|
|
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
|
|
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
|
|
)
|
|
RETURNING id INTO v_intake_id;
|
|
|
|
UPDATE public.patient_invites
|
|
SET uses = uses + 1
|
|
WHERE token = p_token;
|
|
|
|
-- Log de sucesso (best-effort, não propaga erro)
|
|
BEGIN
|
|
INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
|
|
VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
|
|
EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
|
|
RETURN v_intake_id;
|
|
END;
|
|
$function$;
|
|
|
|
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) IS
|
|
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas; exige consent=true; registra cada tentativa em patient_invite_attempts (A#24).';
|