Files
agenciapsilmno/database-novo/migrations/20260419000010_documents_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

305 lines
13 KiB
PL/PgSQL

-- =============================================================================
-- Migration: 20260419000010_documents_security_hardening
-- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos
-- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes
-- pra próxima sessão (precisam de design/decisão).
--
-- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}"
-- (storage.foldername(name))[1] = tenant_id
-- =============================================================================
-- Tabelas de documents são owned por supabase_admin
SET LOCAL ROLE supabase_admin;
-- ─────────────────────────────────────────────────────────────────────────
-- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs"
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects;
CREATE POLICY "documents: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "documents: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'documents'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "documents: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects;
CREATE POLICY "generated-docs: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'generated-docs'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#45: documents — policies separadas por cmd
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: owner full access" ON public.documents;
DROP POLICY IF EXISTS "documents: select" ON public.documents;
DROP POLICY IF EXISTS "documents: insert" ON public.documents;
DROP POLICY IF EXISTS "documents: update" ON public.documents;
DROP POLICY IF EXISTS "documents: delete" ON public.documents;
-- SELECT: owner OR tenant_member ativo OR saas_admin
CREATE POLICY "documents: select" ON public.documents
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller
CREATE POLICY "documents: insert" ON public.documents
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- UPDATE: só owner
CREATE POLICY "documents: update" ON public.documents
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
-- DELETE: só owner ou saas_admin
CREATE POLICY "documents: delete" ON public.documents
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#46: document_share_links — RPC validate_share_token + remover SELECT direto
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
sl document_share_links%ROWTYPE;
v_doc documents%ROWTYPE;
v_token text;
BEGIN
v_token := nullif(btrim(coalesce(p_token, '')), '');
IF v_token IS NULL THEN
RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
END IF;
SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF sl.ativo IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- Incrementa uso atomicamente
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
-- Loga acesso (best-effort)
BEGIN
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
FROM documents d WHERE d.id = sl.document_id;
EXCEPTION WHEN OTHERS THEN
-- não derruba a request se log falhar (schema pode variar)
NULL;
END;
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
RETURN jsonb_build_object(
'document_id', sl.document_id,
'bucket', v_doc.storage_bucket,
'bucket_path', v_doc.bucket_path,
'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type,
'tamanho_bytes', v_doc.tamanho_bytes
);
END;
$function$;
REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role;
-- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário)
DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links;
DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links;
CREATE POLICY "dsl: creator full access" ON public.document_share_links
FOR ALL TO authenticated
USING (criado_por = auth.uid() OR public.is_saas_admin())
WITH CHECK (criado_por = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: select" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: update" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures;
CREATE POLICY "ds: select" ON public.document_signatures
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller
-- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row
-- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo)
CREATE POLICY "ds: insert" ON public.document_signatures
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
AND (signatario_id IS NULL OR signatario_id = auth.uid())
);
-- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado')
CREATE POLICY "ds: update" ON public.document_signatures
FOR UPDATE TO authenticated
USING (signatario_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin());
-- DELETE: signatário, saas_admin ou tenant_admin/owner
CREATE POLICY "ds: delete" ON public.document_signatures
FOR DELETE TO authenticated
USING (
signatario_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#48: document_access_logs — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs;
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#49: document_templates — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates;
DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates;
CREATE POLICY "dt: owner can insert" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (
is_global = false
AND owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (is_global = true AND public.is_saas_admin());