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>
404 lines
16 KiB
PL/PgSQL
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.';
|