Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS

Continuacao de 7c20b51. Esta etapa fechou TODA revisao senior do SaaS
(15 areas auditadas) + refator parcial de pacientes.

Ver commit.md para descricao completa por sessao.

# Estado final do projeto
- A# auditoria abertos: 1 (A#31 Deploy real)
- V# verificacoes abertos: 14 (todos medios/baixos adiados com plano)
- Criticos: 0
- Altos: 0
- Vitest: 208/208 (era 192, +16 nos novos composables)
- SQL integration: 33/33
- E2E (Playwright): 5/5
- Areas auditadas: 15

# Highlights
- Documentos 100% fechado (V#50/51/52: portal-paciente policy + content_sha256 + 4 cron jobs retention)
- Tenants V#1 P0: tenant_invites com RLS off + 0 policies (mesmo padrao A#30)
- Calendario 100% fechado: feriados WITH CHECK
- Addons V#1 P0 (dinheiro): addon_transactions WITH CHECK saas_admin
- Central SaaS V#1: faq write so saas_admin (era tenant_admin)
- Servicos/Prontuarios 100% fechado: services/medicos/insurance_plans + cascades
- Pacientes V#9: 2 composables novos (useCep, usePatientSupportContacts) + repo estendido + script extraido (template intocado, fica para quando houver E2E)

# 8 migrations novas neste commit
- 20260419000011_documents_portal_patient_policy.sql
- 20260419000012_documents_content_hash.sql
- 20260419000013_cron_retention_jobs.sql
- 20260419000014_financial_security_hardening.sql
- 20260419000015_communication_security_hardening.sql
- 20260419000016_tenants_calendario_hardening.sql
- 20260419000017_addons_central_saas_hardening.sql
- 20260419000018_servicos_prontuarios_hardening.sql

Total acumulado: 18 migrations (Sessoes 1-10).

# A#31 reformulado pra proxima sessao
"Deploy real" muda escopo: como nao ha cloud Supabase nem secrets reais
ainda (MVP), proxima sessao vira "Preparacao completa pra deploy" (DEPLOY.md,
validar migrations num container limpo, audit edge functions, listar env vars,
script db.cjs deploy-check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-19 22:00:06 -03:00
parent 7c20b518d4
commit d6eb992f71
18 changed files with 1699 additions and 313 deletions
@@ -0,0 +1,24 @@
-- =============================================================================
-- Migration: 20260419000011_documents_portal_patient_policy
-- V#50 — paciente vê documento via portal quando compartilhado_portal=true.
--
-- Adiciona policy SELECT ADICIONAL em documents (combina via OR com a policy
-- existente "documents: select"). Paciente conseguem ler documentos próprios
-- quando o terapeuta compartilhou via portal.
-- =============================================================================
DROP POLICY IF EXISTS "documents: portal patient read" ON public.documents;
CREATE POLICY "documents: portal patient read" ON public.documents
FOR SELECT TO authenticated
USING (
compartilhado_portal = true
AND patient_id IN (
SELECT p.id FROM public.patients p
WHERE p.user_id = auth.uid()
)
AND (expira_compartilhamento IS NULL OR expira_compartilhamento > now())
);
COMMENT ON POLICY "documents: portal patient read" ON public.documents IS
'V#50: paciente lê documento quando compartilhado_portal=true E patient_id pertence ao auth.uid + não expirou.';
@@ -0,0 +1,18 @@
-- =============================================================================
-- Migration: 20260419000012_documents_content_hash
-- V#51 — hash SHA-256 do conteúdo pra detecção de tampering.
--
-- Coluna nullable (documentos antigos não têm). Calculado client-side via
-- crypto.subtle.digest('SHA-256') antes do upload pro storage.
-- Integridade pode ser verificada baixando o arquivo e recalculando o hash.
-- =============================================================================
ALTER TABLE public.documents
ADD COLUMN IF NOT EXISTS content_sha256 text;
CREATE INDEX IF NOT EXISTS idx_documents_content_sha256
ON public.documents (content_sha256)
WHERE content_sha256 IS NOT NULL;
COMMENT ON COLUMN public.documents.content_sha256 IS
'V#51: SHA-256 hex (64 chars) do conteúdo no momento do upload. Permite verificar integridade. NULL pra documentos legados pré-V#51.';
@@ -0,0 +1,65 @@
-- =============================================================================
-- Migration: 20260419000013_cron_retention_jobs
-- V#52 — retention automática de logs/challenges via pg_cron.
--
-- Jobs:
-- • document_access_logs_cleanup — diário, retém 1 ano (CFP típico)
-- • math_challenges_cleanup — horário, remove expirados há >1h
-- • public_submission_attempts_cleanup — diário, retém 90 dias
-- =============================================================================
-- Garante extensão (idempotente em ambientes que não têm)
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- ─────────────────────────────────────────────────────────────────────────
-- document_access_logs: retém 1 ano (suficiente pra auditoria CFP)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('document_access_logs_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'document_access_logs_cleanup');
SELECT cron.schedule(
'document_access_logs_cleanup',
'0 3 * * *', -- todo dia às 03:00
$$DELETE FROM public.document_access_logs WHERE created_at < now() - interval '1 year'$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- math_challenges: remove expirados (> 1h após expiração)
-- (RPC cleanup_expired_math_challenges já existe desde 20260419000007)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('math_challenges_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'math_challenges_cleanup');
SELECT cron.schedule(
'math_challenges_cleanup',
'0 * * * *', -- toda hora
$$SELECT public.cleanup_expired_math_challenges()$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- public_submission_attempts: retém 90 dias (analytics + alertas)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('public_submission_attempts_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'public_submission_attempts_cleanup');
SELECT cron.schedule(
'public_submission_attempts_cleanup',
'15 3 * * *', -- todo dia 03:15 (após o de docs)
$$DELETE FROM public.public_submission_attempts WHERE created_at < now() - interval '90 days'$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- submission_rate_limits: limpa entradas antigas (>30 dias sem atividade)
-- (estados expirados não fazem mal, mas tabela cresce sem limite)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('submission_rate_limits_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'submission_rate_limits_cleanup');
SELECT cron.schedule(
'submission_rate_limits_cleanup',
'30 3 * * *', -- todo dia 03:30
$$DELETE FROM public.submission_rate_limits
WHERE last_attempt_at < now() - interval '30 days'
AND (blocked_until IS NULL OR blocked_until < now())
AND (requires_captcha_until IS NULL OR requires_captcha_until < now())$$
);
@@ -0,0 +1,117 @@
-- =============================================================================
-- Migration: 20260419000014_financial_security_hardening
-- Sessão 6 — revisão Financeiro. Resolve V#1-V#5 (2 críticos + 3 altos).
-- V#6-V#11 adiados (médios/baixos com plano).
--
-- Auditoria prévia confirmou:
-- • 0 financial_records com tenant_id NULL
-- • 0 records com clinic_fee_amount > amount
-- → seguro aplicar NOT NULL e CHECK constraints.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1: billing_contracts policy granular
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "billing_contracts: owner full access" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: select" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: insert" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: update" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: delete" ON public.billing_contracts;
CREATE POLICY "billing_contracts: select" ON public.billing_contracts
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'
)
);
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts
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'
)
);
CREATE POLICY "billing_contracts: update" ON public.billing_contracts
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());
CREATE POLICY "billing_contracts: delete" ON public.billing_contracts
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#2: financial_records.tenant_id NOT NULL + trigger backfill
-- (auditoria: 0 órfãos, seguro aplicar)
-- -----------------------------------------------------------------------------
ALTER TABLE public.financial_records ALTER COLUMN tenant_id SET NOT NULL;
-- Trigger defensivo: se tentar inserir sem tenant_id, busca via owner_id->tenant_members
CREATE OR REPLACE FUNCTION public.financial_records_inject_tenant()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
SELECT tm.tenant_id INTO NEW.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
ORDER BY tm.created_at DESC
LIMIT 1;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_financial_records_inject_tenant ON public.financial_records;
CREATE TRIGGER trg_financial_records_inject_tenant
BEFORE INSERT ON public.financial_records
FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
-- ─────────────────────────────────────────────────────────────────────────
-- V#5: financial_records CHECK contra net_amount negativo
-- -----------------------------------------------------------------------------
ALTER TABLE public.financial_records
DROP CONSTRAINT IF EXISTS financial_records_fee_lte_amount_chk;
ALTER TABLE public.financial_records
ADD CONSTRAINT financial_records_fee_lte_amount_chk
CHECK (clinic_fee_amount IS NULL OR (clinic_fee_amount >= 0 AND clinic_fee_amount <= amount));
-- ─────────────────────────────────────────────────────────────────────────
-- V#3: payment_settings — adicionar SELECT pra tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "payment_settings: tenant_admin read" ON public.payment_settings;
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings
FOR SELECT TO authenticated
USING (
tenant_id IS NOT NULL
AND 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')
)
);
-- (a policy ALL "owner full access" continua — owner mexe nos próprios)
-- ─────────────────────────────────────────────────────────────────────────
-- V#4: professional_pricing — adicionar SELECT pra tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "professional_pricing: tenant_admin read" ON public.professional_pricing;
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing
FOR SELECT TO authenticated
USING (
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')
)
);
@@ -0,0 +1,127 @@
-- =============================================================================
-- Migration: 20260419000015_communication_security_hardening
-- Sessão 6 — revisão Comunicação. Resolve V#1-V#5 (2 críticos + 3 altos).
-- V#6-V#10 adiados (médios/baixos com plano completo no DB).
--
-- 🔴 V#1+V#2 são bugs P0: policies usavam (tenant_id = auth.uid()) — comparação
-- de UUID de tenant com UUID de user. Tabelas inacessíveis na prática.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1: email_layout_config — fix BUG do tenant_id = auth.uid()
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "tenant owns email layout config" ON public.email_layout_config;
DROP POLICY IF EXISTS "email_layout_config: tenant_admin all" ON public.email_layout_config;
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config
FOR ALL 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'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
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#2: email_templates_tenant — MESMO bug
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "tenant manages own overrides" ON public.email_templates_tenant;
DROP POLICY IF EXISTS "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant;
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant
FOR ALL 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'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
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#3: notification_logs — SELECT pra tenant_member
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notif_logs_tenant_member" ON public.notification_logs;
CREATE POLICY "notif_logs_tenant_member" ON public.notification_logs
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'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#4: notification_queue — SELECT pra tenant_member
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notif_queue_tenant_member" ON public.notification_queue;
CREATE POLICY "notif_queue_tenant_member" ON public.notification_queue
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'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#5: notification_channels — SELECT pra tenant_member; INSERT tenant_admin; UPDATE/DELETE owner
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notification_channels_owner" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_select" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_modify" ON public.notification_channels;
CREATE POLICY "notif_channels_select" ON public.notification_channels
FOR SELECT TO authenticated
USING (
deleted_at IS NULL
AND (
public.is_saas_admin()
OR owner_id = auth.uid()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "notif_channels_insert" ON public.notification_channels
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'
)
);
CREATE POLICY "notif_channels_modify" ON public.notification_channels
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());
CREATE POLICY "notif_channels_delete" ON public.notification_channels
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
@@ -0,0 +1,157 @@
-- =============================================================================
-- Migration: 20260419000016_tenants_calendario_hardening
-- Sessão 7 — Tenants + Calendário scan (corrige críticos + altos + WITH CHECKs).
--
-- Resolve:
-- • Tenants V#1 (P0) — tenant_invites RLS off + 0 policies
-- • Tenants V#2 — profiles_insert_own sem WITH CHECK
-- • Tenants V#3 — support_sessions_saas_insert sem WITH CHECK
-- • Tenants V#6 — user_settings_insert_own sem WITH CHECK
-- • Calendário V#1 — feriados_insert + feriados_saas_insert sem WITH CHECK
--
-- Auditoria prévia: tenant_invites tem 0 rows (seguro habilitar RLS sem
-- migração de dados).
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#1 (P0): tenant_invites
-- -----------------------------------------------------------------------------
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.tenant_invites FROM anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_invites TO authenticated;
-- SELECT: tenant_admin/admin/owner do tenant + saas_admin
DROP POLICY IF EXISTS tenant_invites_select ON public.tenant_invites;
CREATE POLICY tenant_invites_select ON public.tenant_invites
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'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- INSERT: só tenant_admin do tenant_id, e invited_by deve ser o caller
DROP POLICY IF EXISTS tenant_invites_insert ON public.tenant_invites;
CREATE POLICY tenant_invites_insert ON public.tenant_invites
FOR INSERT TO authenticated
WITH CHECK (
invited_by = 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'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- UPDATE: só revogação por tenant_admin do tenant. Aceitar é via RPC tenant_accept_invite (SECURITY DEFINER).
DROP POLICY IF EXISTS tenant_invites_update ON public.tenant_invites;
CREATE POLICY tenant_invites_update ON public.tenant_invites
FOR UPDATE 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'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
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')
)
);
-- DELETE: tenant_admin OR saas_admin
DROP POLICY IF EXISTS tenant_invites_delete ON public.tenant_invites;
CREATE POLICY tenant_invites_delete ON public.tenant_invites
FOR DELETE 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'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
COMMENT ON TABLE public.tenant_invites IS
'Convites pra entrar em tenant. Aceitar deve ser via RPC tenant_accept_invite (SECURITY DEFINER). Criar/revogar via UI por tenant_admin.';
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#2: profiles INSERT WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS profiles_insert_own ON public.profiles;
CREATE POLICY profiles_insert_own ON public.profiles
FOR INSERT TO authenticated
WITH CHECK (id = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#3: support_sessions INSERT WITH CHECK
-- (admin_id deve ser o caller E o caller deve ser saas_admin)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS support_sessions_saas_insert ON public.support_sessions;
CREATE POLICY support_sessions_saas_insert ON public.support_sessions
FOR INSERT TO authenticated
WITH CHECK (
admin_id = auth.uid()
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#6: user_settings INSERT WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS user_settings_insert_own ON public.user_settings;
CREATE POLICY user_settings_insert_own ON public.user_settings
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- Calendário V#1: feriados INSERT WITH CHECK (tenant + global)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS feriados_insert ON public.feriados;
CREATE POLICY feriados_insert ON public.feriados
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
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'
)
);
DROP POLICY IF EXISTS feriados_saas_insert ON public.feriados;
CREATE POLICY feriados_saas_insert ON public.feriados
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IS NULL
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Calendário V#2: feriados DELETE — adicionar tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS feriados_delete ON public.feriados;
CREATE POLICY feriados_delete ON public.feriados
FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR (tenant_id IS NOT NULL AND 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')
))
);
@@ -0,0 +1,65 @@
-- =============================================================================
-- Migration: 20260419000017_addons_central_saas_hardening
-- Sessão 8 — Addons + Central SaaS scan.
--
-- Resolve:
-- • Addons V#1 (CRÍTICO — dinheiro real): addon_transactions sem WITH CHECK
-- • Addons V#2: addon_credits sem CHECK contra saldo negativo
-- • Central SaaS V#1: saas_faq write permite tenant_admin/clinic_admin
--
-- Auditoria prévia: 0 addon_credits com balance < 0 (seguro CHECK).
-- Edge functions consomem créditos via service_role (bypass RLS) — nova
-- restrição não quebra pipeline.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Addons V#1: addon_transactions INSERT WITH CHECK (saas_admin only)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS addon_transactions_admin_insert ON public.addon_transactions;
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Addons V#2: addon_credits CHECK contra saldo negativo
-- -----------------------------------------------------------------------------
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_balance_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_balance_nonneg_chk
CHECK (balance >= 0);
-- Aproveita: total_consumed também não deve ser negativo
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_consumed_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_consumed_nonneg_chk
CHECK (total_consumed >= 0);
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_purchased_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_purchased_nonneg_chk
CHECK (total_purchased >= 0);
-- ─────────────────────────────────────────────────────────────────────────
-- Central SaaS V#1: saas_faq + saas_faq_itens write SÓ saas_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS faq_admin_write ON public.saas_faq;
CREATE POLICY faq_saas_admin_write ON public.saas_faq
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
DROP POLICY IF EXISTS faq_itens_admin_write ON public.saas_faq_itens;
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- (Policies de leitura — faq_auth_read, faq_public_read, faq_itens_auth_read — permanecem)
@@ -0,0 +1,223 @@
-- =============================================================================
-- Migration: 20260419000018_servicos_prontuarios_hardening
-- Sessão 9 — Serviços/Prontuários scan.
--
-- Resolve:
-- • Serviços V#1+V#2 (CRÍTICOS): silos por owner em services/medicos/insurance_plans
-- • Serviços V#3+V#4 (ALTOS): cascade silos em commitment_services/insurance_plan_services
-- • Serviços V#5: WITH CHECK ausente em commitment_time_logs/determined_*
--
-- Padrão validado em 5 áreas anteriores (Documentos/Financeiro/Comunicação/etc):
-- SELECT tenant_member, INSERT/UPDATE/DELETE owner+saas, com WITH CHECK explícito.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1 services — split em 4 policies
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "services: owner full access" ON public.services;
DROP POLICY IF EXISTS "services: select" ON public.services;
DROP POLICY IF EXISTS "services: insert" ON public.services;
DROP POLICY IF EXISTS "services: update" ON public.services;
DROP POLICY IF EXISTS "services: delete" ON public.services;
CREATE POLICY "services: select" ON public.services
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'
)
);
CREATE POLICY "services: insert" ON public.services
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'
)
);
CREATE POLICY "services: update" ON public.services
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());
CREATE POLICY "services: delete" ON public.services
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#2 medicos — mesmo padrão
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "medicos: owner full access" ON public.medicos;
DROP POLICY IF EXISTS "medicos: select" ON public.medicos;
DROP POLICY IF EXISTS "medicos: insert" ON public.medicos;
DROP POLICY IF EXISTS "medicos: update" ON public.medicos;
DROP POLICY IF EXISTS "medicos: delete" ON public.medicos;
CREATE POLICY "medicos: select" ON public.medicos
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'
)
);
CREATE POLICY "medicos: insert" ON public.medicos
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'
)
);
CREATE POLICY "medicos: update" ON public.medicos
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());
CREATE POLICY "medicos: delete" ON public.medicos
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#1 (parte 2) insurance_plans — mesmo padrão
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "insurance_plans: owner full access" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: select" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: insert" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: update" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: delete" ON public.insurance_plans;
CREATE POLICY "insurance_plans: select" ON public.insurance_plans
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'
)
);
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans
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'
)
);
CREATE POLICY "insurance_plans: update" ON public.insurance_plans
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());
CREATE POLICY "insurance_plans: delete" ON public.insurance_plans
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#3 commitment_services — cascade via JOIN com services.tenant_id
-- (tabela N:N sem tenant_id próprio; herda do services pai)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "commitment_services: owner full access" ON public.commitment_services;
DROP POLICY IF EXISTS "commitment_services: tenant_member" ON public.commitment_services;
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.services s
WHERE s.id = commitment_services.service_id
AND (
s.owner_id = auth.uid()
OR public.is_saas_admin()
OR s.tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.services s
WHERE s.id = commitment_services.service_id
AND (s.owner_id = auth.uid() OR public.is_saas_admin())
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#4 insurance_plan_services — cascade via JOIN com insurance_plans
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "insurance_plan_services_owner" ON public.insurance_plan_services;
DROP POLICY IF EXISTS "insurance_plan_services: tenant_member" ON public.insurance_plan_services;
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.insurance_plans ip
WHERE ip.id = insurance_plan_services.insurance_plan_id
AND (
ip.owner_id = auth.uid()
OR public.is_saas_admin()
OR ip.tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.insurance_plans ip
WHERE ip.id = insurance_plan_services.insurance_plan_id
AND (ip.owner_id = auth.uid() OR public.is_saas_admin())
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#5 — adicionar WITH CHECK em INSERT das 3 tabelas que não tinham
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS ctl_insert_for_active_member ON public.commitment_time_logs;
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_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'
)
);
DROP POLICY IF EXISTS dcf_insert_for_active_member ON public.determined_commitment_fields;
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields
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'
)
);
DROP POLICY IF EXISTS dc_insert_for_active_member ON public.determined_commitments;
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments
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'
)
);