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>
305 lines
13 KiB
PL/PgSQL
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());
|