Files
agenciapsilmno/database-novo/migrations/20260418000002_patient_intake_security_hardening.sql
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
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>
2026-04-19 15:42:46 -03:00

404 lines
16 KiB
PL/PgSQL

-- =============================================================================
-- Migration: 20260418000002_patient_intake_security_hardening
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
-- -----------------------------------------------------------------------------
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
-- avatars + storage policies.
--
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
-- whitelist de valores, validação de token completa (active/expires/max_uses).
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. create_patient_intake_request_v2 — versão hardened
-- -----------------------------------------------------------------------------
-- Mudanças vs versão anterior:
-- • A#16: valida active, expires_at, max_uses; incrementa uses no final
-- • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
-- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
-- • A#27: length checks em TODOS os campos texto
-- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
-- lower em emails, whitelist para genero/estado_civil
-- • Consent obrigatório (raise se false)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
p_token text,
p_payload jsonb
)
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;
-- Whitelists para campos tipados
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Carrega invite e 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
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Resolver tenant_id (A#19)
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
-- ───────────────────────────────────────────────────────────────────────
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)
-- ───────────────────────────────────────────────────────────────────────
-- Nome obrigatório (max 200)
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
RAISE EXCEPTION 'Nome é obrigatório';
END IF;
IF length(v_nome) > 200 THEN
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
END IF;
-- Email principal obrigatório + lower + max 120
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
RAISE EXCEPTION 'E-mail é obrigatório';
END IF;
IF length(v_email) > 120 THEN
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail inválido';
END IF;
-- Email alternativo opcional mas validado se presente
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
RAISE EXCEPTION 'E-mail alternativo muito longo';
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail alternativo inválido';
END IF;
END IF;
-- Consent obrigatório
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
RAISE EXCEPTION 'Consentimento é obrigatório';
END IF;
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
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;
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
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 com sanitização inline
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
-- do terapeuta, não deve vir do paciente.
-- ───────────────────────────────────────────────────────────────────────
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;
-- Incrementa contador de uso (A#16)
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
RETURN v_intake_id;
END;
$function$;
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) 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 (campo interno); exige consent=true.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
-- -----------------------------------------------------------------------------
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
-- Novo: gera gen_random_uuid() server-side e retorna.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_new_token text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
v_new_token := replace(gen_random_uuid()::text, '-', '');
-- Resolve tenant_id do usuário (active)
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
-- Desativa tokens ativos anteriores
UPDATE public.patient_invites
SET active = false
WHERE owner_id = v_uid
AND active = true;
-- Insere novo
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_new_token, true);
RETURN v_new_token;
END;
$function$;
COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';
GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
-- -----------------------------------------------------------------------------
-- Substitui o client-side newToken() + direct insert em patient_invites.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_token text;
v_existing text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
SELECT token
INTO v_existing
FROM public.patient_invites
WHERE owner_id = v_uid
AND active = true
ORDER BY created_at DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
v_token := replace(gen_random_uuid()::text, '-', '');
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_token, true);
RETURN v_token;
END;
$function$;
COMMENT ON FUNCTION public.issue_patient_invite() IS
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';
GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
-- -----------------------------------------------------------------------------
UPDATE storage.buckets
SET file_size_limit = 5242880, -- 5 MB
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
WHERE id = 'avatars';
-- ─────────────────────────────────────────────────────────────────────────
-- 5. Storage policies — remover upload anon irrestrito (A#15)
-- -----------------------------------------------------------------------------
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
-- arquivo. Removemos essas policies. Upload público passa a exigir token
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;
-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
-- o terapeuta vê no painel de cadastros recebidos).
CREATE POLICY "intake_read_owner_only"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = 'intakes'
);
COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';