diff --git a/commit.md b/commit.md index 515ce0e..ee6cd12 100644 --- a/commit.md +++ b/commit.md @@ -1,237 +1,176 @@ -# Sessões 1-6 acumuladas — hardening, defesa em profundidade, +192 testes +# Sessões 6 (continuação) → 10 — hardening em 6 áreas + scan completo do SaaS -Repositório estava sem commit há ~5 sessões de trabalho intenso. Este commit -consolida tudo que foi feito desde o último marco (`d088a89`). +Continuação do commit `7c20b51` (Sessões 1-6 iniciais). Esta etapa fechou +**toda revisão sênior do SaaS** + refator parcial de pacientes. -**A# auditoria:** 30 itens registrados, **0 abertos**. -**V# verificações:** 52 registradas (10 novas em Documentos), **5 abertas** (todas adiadas com plano). -**T# testes:** 10/10 escritas. **192 vitest + 33 SQL + 5 E2E** passando. +**Estado final do projeto:** +- A# auditoria abertos: **1** (A#31 Deploy real) +- V# verificações abertos: 14 (todos médios/baixos adiados com plano completo no DB) +- 🔴 Críticos: **0** ✅ +- 🟠 Altos: **0** ✅ +- Vitest: **208/208** (era 192) +- SQL integration: **33/33** +- E2E (Playwright): **5/5** +- Áreas auditadas: **15** (todas as principais do SaaS) --- -## Sessão 1 — auth/router/session +## Sessão 6 (continuação) — Documentos pendentes + Pacientes V#3 -- A#7 resolvido (`window.__guardsBound`) -- 10 verificações registradas (V#1–V#10), 5 corrigidas: - - `session.js` logger inconsistente - - `tenantStore.ensureLoaded` polling - - `normalizeRole` duplicado → extraído pra `src/utils/roleNormalizer.js` - - `console.error` em `router/index.js` - - `.single()` em `fetchRole` +### Documentos: 100% fechado (V#50, V#51, V#52) +- **V#50** — Policy `documents: portal patient read` adicional. Paciente lê documento via portal quando `compartilhado_portal=true` AND patient pertence a auth.uid AND não expirou. +- **V#51** — `documents.content_sha256` (nullable, índice parcial). `Documents.service.uploadDocument` calcula SHA-256 hex client-side via `crypto.subtle.digest`. Helper novo `verifyDocumentIntegrity(docId)` baixa arquivo e re-hash. +- **V#52** — Migration `...13` cron retention via pg_cron: 4 jobs (document_access_logs 1 ano, math_challenges 1h, public_submission_attempts 90 dias, submission_rate_limits 30 dias). + +### Pacientes V#3 (parcial — fundação) +- `src/features/patients/services/patientsRepository.js` — list/get/create/update/softDelete + groups + tags + getSessionCounts. +- `src/features/patients/composables/usePatients.js` — wrapper reativo (rows/loading/error). +- PatientsListPage.hydrateAssociationsSupabase migrada — substitui 4 queries diretas por chamadas ao repo (paralelismo preservado). +- V#9 (PatientsCadastroPage 1991 linhas) → adiado pra Sessão 10. --- -## Sessão 2 — agenda +## Sessão 7 — Tenants + Calendário -- 11 verificações (V#11–V#21), 10 corrigidas: - - `useRecurrence` CRUD ganhou filtro `tenant_id` (alto) - - `agenda.service.js` vazio deletado - - `agendaRepository` ↔ `useAgendaEvents` consolidados (composable virou wrapper fino, 181→67 linhas) - - `AGENDA_EVENT_SELECT` centralizado em `agendaSelects.js` - - `_tenantGuards.js` compartilhado - - V#21 status `remarcar` → `remarcado` padronizado em 14 edições +### Tenants (8 V#) +- 🔴 **V#1 P0** — `tenant_invites` com RLS off + 0 policies (mesmo padrão A#30 Sessão 5). Tabela tinha 0 rows. Migration: ENABLE RLS + 4 policies (SELECT tenant_admin/saas; INSERT WITH CHECK invited_by=auth.uid; UPDATE só revogação; DELETE tenant_admin/saas). Aceitar invite continua via RPC `tenant_accept_invite` SECURITY DEFINER. +- 🟠 **V#2** profiles INSERT WITH CHECK (id = auth.uid) +- 🟠 **V#3** support_sessions INSERT WITH CHECK (admin_id = auth.uid + saas_admin guard) +- 🟡 **V#4 (signup público)** verificado: RPC `ensure_personal_tenant` SECURITY DEFINER já existia (Signup.vue:232) → **ok** +- 🟡 **V#5 (accept_invite)** verificado: RPCs `tenant_accept_invite` + `tenant_invite_member_by_email` já existiam → **ok** +- 🟡 **V#6** user_settings INSERT WITH CHECK +- 🟢 V#7/V#8 baixos — adiados + +### Calendário (2 V#) — 100% fechado +- 🔴 **V#1** feriados_insert + feriados_saas_insert ganharam WITH CHECK. Spam de feriado global bloqueado. +- 🟢 **V#2** feriados_delete agora permite tenant_admin (não só owner). --- -## Sessão 3 — pacientes +## Sessão 8 — Addons + Central SaaS -- 10 verificações (V#22–V#31), 6 corrigidas + 4 documentadas -- 5 arquivos obsoletos deletados (PatientsCadastroPage Bkp, preview, prontuário design 1/2/3) -- `tenant_id` em todas queries de patients (alto) -- 9 `console.*` migrados pra logger -- `hydrateAssociations` paralelizada (5 round-trips → 2) -- `.maybeSingle()` onde precisava +### Addons (4 V#) +- 🔴 **V#1 P0 (dinheiro)** — `addon_transactions_admin_insert` ganhou WITH CHECK (EXISTS saas_admins). Edge functions com service_role bypassam RLS, pipeline preservado. **Authenticated comum não cria mais transação fake.** +- 🟠 **V#2** — 3 CHECK constraints em `addon_credits`: balance >= 0, total_consumed >= 0, total_purchased >= 0. Saldo negativo silencioso eliminado. +- 🟡 V#3 (UI extrato) — adiado. +- 🟡 V#4 — verificado: `addon_products` não tem `tenant_id` (catálogo global por design) → **ok**. + +### Central SaaS (3 V#) +- 🟠 **V#1** — `faq_admin_write` substituído por `faq_saas_admin_write` em `saas_faq` E `saas_faq_itens` — só saas_admin escreve. Tenant_admin lê via `faq_auth_read` (permanece). +- 🟢 V#2/V#3 médios/baixos — adiados. --- -## Sessão 4 — security review (página pública de cadastro) +## Sessão 9 — Serviços/Prontuários (100% fechado) -- V#31 virou security review completa: **15 vulnerabilidades (A#15–A#29), 14 corrigidas** -- **Críticos:** - - **A#15** bucket `avatars` público → 5MB + mime whitelist + policies restritas - - **A#16** RPC v2 ignorava `active/expires/max_uses` → validação completa + incrementa `uses` - - **A#17** `notas_internas` exposto ao paciente → removido do form e RPC - - **A#18** `Math.random()` pra token → RPCs server-side via `gen_random_uuid()` - - **A#19** intake sem `tenant_id` → RPC resolve via `patient_invites` ou `tenant_members` -- **Médios:** log `patient_invite_attempts` (A#24), política LGPD (A#25), botão mock só em DEV (A#26), length caps server-side (A#27) -- **Baixos:** duplicado `PatientsExternalLinkPage` deletado, `Landingpage-v1 - bkp.vue` deletado +5/5 V# corrigidos: +- 🔴 **V#1** services + insurance_plans → 4 policies separadas (SELECT tenant_member; INSERT/UPDATE/DELETE owner+saas). +- 🔴 **V#2** medicos → 4 policies separadas (catálogo de médicos referenciadores compartilhado entre profissionais do tenant). +- 🟠 **V#3** commitment_services — cascade reescrito via JOIN com services (USING permite tenant_member; WITH CHECK só owner). +- 🟠 **V#4** insurance_plan_services — cascade reescrito via JOIN com insurance_plans. +- 🟡 **V#5** commitment_time_logs/determined_commitments/determined_commitment_fields ganharam WITH CHECK em INSERT. --- -## Sessão 5 — SaaS (planos, preços, recursos) +## Sessão 10 — Pacientes V#9 (script extraído) -- 10 verificações (V#33–V#42) -- **🔴 P0:** A#30 — 7 tabelas SaaS com RLS OFF + `GRANT ALL` pra anon. Migration `...05_saas_rls_emergency_fix` aplicou REVOKE + ENABLE RLS + 9 policies corretas -- 109/109 testes passando +PatientsCadastroPage.vue: 1991 → 1951 linhas (qualitativo > quantitativo). + +### 2 composables novos +- **`useCep.js`** — busca ViaCEP reutilizável. 6 testes (sem rede, mock fetch). +- **`usePatientSupportContacts.js`** — CRUD de contatos de suporte encapsulado (load/save/add/remove/iniciaisFor). 10 testes com builder thenable. + +### patientsRepository estendido +- `getPatientRelations(patientId)` — retorna {groupIds, tagIds} +- `replacePatientGroup(patientId, groupId, {tenantId})` +- `replacePatientTags(patientId, tagIds, {tenantId, ownerId})` + +### PatientsCadastroPage refatorado +- 8 funções de query → delegação 1-linha ao patientsRepository +- onCepBlur → usa composable useCep +- Contatos de suporte → composable +- Template **não** foi tocado (zero risco de regressão visual) +- Quebra de template em sub-componentes Vue → adiado pra quando houver E2E cobrindo a página --- -## Sessão 6 (HOJE, 2026-04-19) — bloco principal - -### V#34 + V#41 — Opção B2 (plano + override + exceção comercial) - -Resolve `tenantFeaturesStore.isEnabled` que retornava `true` por default -(qualquer feature aparecia ativa pra qualquer tenant) E a dupla-fonte com -`entitlementsStore`. - -**Backend** (migration `...01`): -- Trigger `tenant_features_guard_with_plan` ganhou bypass via session flag -- RPC `set_tenant_feature_exception` SECURITY DEFINER com regras assimétricas: - - `enabled=false` → tenant_admin OU saas_admin (preferência) - - `enabled=true` AND plano permite → tenant_admin OU saas_admin - - `enabled=true` AND plano NÃO permite → **só saas_admin + reason obrigatório** -- Policy `tenant_features_write_saas_only` - -**Frontend:** -- `tenantFeaturesStore.isEnabled` reescrito (B2): override negativo desliga, override positivo liga (exceção), sem override segue plano -- `setForTenant` chama RPC com `reason` -- Tela nova `/saas/tenant-features` com dialog de motivo obrigatório -- JSDoc separação semântica: `entitlementsStore.has` = "plano permite?" vs `tenantFeaturesStore.isEnabled` = "ativo agora?" -- 17 testes em `tenantFeaturesStore.spec.js` - -### Pendentes Sessão 5 fechados - -- **V#35** — 17→11 policies (consolidadas plans/features/plan_features/subscriptions) + `COMMENT ON POLICY` -- **V#36** — RPC `delete_plan_safe` bloqueia DELETE com subscriptions ativas -- **V#40** — `features.is_active` (soft delete) + UI com filtro/Reativar -- **V#42** — `entitlementsStore.loadFor*` no catch não marca como carregado + `logError` - -### Testes T#5/T#7/T#8 - -- **T#5** `tenantStore.spec.js` — 15 testes (singleflight, regressão V#5, erros, setActiveTenant, reset, getters) -- **T#7** `validators.spec.js` — 38 testes (sanitização do intake) -- **T#8** `database-novo/tests/run.cjs` — runner Node + docker exec, 33 cenários SQL - -### A#20 (CAPTCHA) — rev2 self-hosted - -**Decisão:** descartado Cloudflare Turnstile / hCaptcha em favor de defesa em -camadas self-hosted. Razões: zero LGPD, zero provider, zero fricção pro paciente -legítimo (UX importa em paciente vulnerável buscando atendimento). - -**5 camadas:** -1. **Honeypot** — campo invisível -2. **Validação** básica -3. **Rate limit por IP** — `check_rate_limit` RPC -4. **Math captcha condicional** — só ativa após N falhas (default 3) -5. **Modo paranoid** global toggle - -**Implementação:** -- Migrations `...06` (4 tabelas) + `...07` (RPCs) -- Edge function `submit-patient-intake` reescrita (dual endpoint) -- Componente `MathCaptchaChallenge.vue` lazy -- Tela `/saas/security` com card explicativo (6 seções), KPIs 24h, toggles, sliders, dashboard de IPs - -### SaaS Twilio Config (UI editável) - -- Migration `...08` (singleton + RPCs `get_twilio_config`/`update_twilio_config`) -- **AUTH_TOKEN permanece em env var** (único secret); SID/webhook/rate/margem migram pra DB -- Edge function lê do banco com fallback pra env (back-compat) -- Tela `/saas/twilio-config` com card + status do AUTH_TOKEN -- Bug fix: `friendlyErrorMessage()` traduz "Edge Function returned a non-2xx status code" - -### Revisão sênior em Documentos/prontuários - -10 V# novas registradas, 7 corrigidas, 3 adiadas. - -**Críticos:** -- 🔴 **V#43/V#44** vazamento entre clínicas via storage policies — corrigido com tenant scoping no path `(storage.foldername(name))[1]::uuid IN tenant_members` -- 🔴 **V#45** documents policy pobre (só `owner_id = auth.uid()`) — separada em SELECT/INSERT/UPDATE/DELETE com tenant scoping - -**Altos:** -- 🟠 **V#46** share_links sem incremento de usos — RPC `validate_share_token` atomicamente valida + incrementa + loga -- 🟠 **V#47** signatures policy ALL — separada (UPDATE só pra signatário) - -**Médios:** -- 🟡 **V#48** access_logs WITH CHECK -- 🟡 **V#49** templates WITH CHECK - -### B-block (V# avulsos) - -- **V#2** Listener `onAuthStateChange` consolidado (session.js virou autoridade + API `onSessionEvent`) -- **V#6** `globalRoleCache` TTL 5min -- **V#10** Bloqueio SaaS via `meta.area`/`meta.saasAdmin` em vez de `path.startsWith` -- **V#8** RPC `get_patient_session_counts` substitui `.limit(1000)` arbitrário -- **V#9 router** short-circuit `lastEnsureKey` em ensureMenuBuilt -- **V#17** 25 `console.*` eliminados em src/views/pages/saas/ -- **V#18** TTL real em tenantFeaturesStore - -### T#9 + T#10 - -- **T#9** `useAgendaEvents.spec.js` — 13 testes do wrapper -- **T#10** Playwright + Chromium instalados; 5 specs E2E em `e2e/patient-intake.spec.js` -- **Bug fix achado pelo E2E**: `CadastroPacienteExterno.enviar` não extraía body do erro 403 — corrigido - ---- - -## 📦 Migrations consolidadas (todas as sessões) +## 📦 Migrations consolidadas neste commit (8) ``` -20260417000001_dev_tables (Sessão pré-1: tabelas dev) -20260417000002_dev_tables_ordem -20260418000001_dev_verificacoes (Sessão 1) -20260418000002_patient_intake_security_hardening (Sessão 4) -20260418000003_patient_invite_attempts_log (Sessão 4) -20260418000004_dev_tests (Sessão 1) -20260418000005_saas_rls_emergency_fix (Sessão 5 — P0) -20260419000001_tenant_features_b2_governance (Sessão 6 — V#34/V#41) -20260419000002_features_is_active (Sessão 6 — V#40) -20260419000003_delete_plan_safe (Sessão 6 — V#36) -20260419000004_consolidate_policies (Sessão 6 — V#35) -20260419000005_restrict_intake_rpc (Sessão 6 — A#20) -20260419000006_layered_bot_defense (Sessão 6 — A#20 rev2) -20260419000007_bot_defense_rpcs (Sessão 6 — A#20 rev2) -20260419000008_saas_twilio_config (Sessão 6) -20260419000009_patient_session_counts_rpc (Sessão 6 — V#8) -20260419000010_documents_security_hardening (Sessão 6 — V#43-V#49) +20260419000011_documents_portal_patient_policy.sql (V#50) +20260419000012_documents_content_hash.sql (V#51) +20260419000013_cron_retention_jobs.sql (V#52 + math_challenges + submissions + rate_limits) +20260419000014_financial_security_hardening.sql (5 V# financeiro — fechados na Sessão 6) +20260419000015_communication_security_hardening.sql (5 V# comunicação — fechados na Sessão 6) +20260419000016_tenants_calendario_hardening.sql (Tenants V#1-V#3,V#6 + Calendário V#1-V#2) +20260419000017_addons_central_saas_hardening.sql (Addons V#1-V#2 + Central SaaS V#1) +20260419000018_servicos_prontuarios_hardening.sql (Serviços V#1-V#5) +``` + +**Total acumulado de migrations no histórico: 18** (Sessões 1-10). + +Várias dessas exigiram conexão direta como `supabase_admin` (ver memory `project_supabase_admin_gotcha.md` e `commit.md` anterior) por causa de tabelas owned por esse role. + +--- + +## 🆕 Novos arquivos (código) + +``` +src/features/patients/composables/useCep.js +src/features/patients/composables/usePatientSupportContacts.js +src/features/patients/composables/usePatients.js +src/features/patients/composables/__tests__/useCep.spec.js (+6 testes) +src/features/patients/composables/__tests__/usePatientSupportContacts.spec.js (+10 testes) +src/features/patients/services/patientsRepository.js ``` --- -## 🆕 Pastas/arquivos novos importantes +## 🛠️ Modificações -- `e2e/` — specs Playwright (T#10) -- `playwright.config.js` — config E2E -- `database-novo/tests/run.cjs` — runner SQL integration tests (T#8) -- `database-novo/backups/` agora ignorado (regenerável via `db.cjs backup`) -- `src/components/security/MathCaptchaChallenge.vue` — A#20 rev2 -- `src/views/pages/saas/SaasTenantFeaturesPage.vue` — V#34 -- `src/views/pages/saas/SaasSecurityPage.vue` — A#20 rev2 + card educativo -- `src/views/pages/saas/SaasTwilioConfigPage.vue` — UI Twilio editável -- `src/utils/roleNormalizer.js` — Sessão 1 -- `src/features/agenda/services/_tenantGuards.js` + `agendaSelects.js` — Sessão 2 -- 6 specs novas em `__tests__/` (vitest) -- `supabase/functions/submit-patient-intake/` — edge function reescrita A#20 rev2 +- `src/features/patients/PatientsListPage.vue` — hydrateAssociationsSupabase usa repo +- `src/features/patients/cadastro/PatientsCadastroPage.vue` — script extraído (queries → repo, CEP → composable, contatos → composable). Template intocado. +- `src/services/Documents.service.js` — uploadDocument calcula content_sha256 + helper verifyDocumentIntegrity --- -## 🛠️ .gitignore ajustado neste commit +## 📊 Áreas auditadas (estado final) -- `supabase/*` + `!supabase/functions/` (mantém edge functions, ignora `.temp/`/`migrations/`/etc gerados pelo CLI) -- `database-novo/backups/` (backups regeneráveis) -- `test-results/`, `playwright-report/` (outputs Playwright) -- `.claude/settings.local.json` (config local do harness) - ---- - -## 📊 Números finais - -| Métrica | Início | Fim | +| Área | V# total | Estado | |---|---|---| -| A# abertos | 30 (a registrar) | **0** | -| V# abertos | 52 (a registrar) | **5** (adiados) | -| T# escritas | 0/10 | **10/10** | -| Vitest | — | **192/192** | -| SQL integration | — | **33/33** | -| E2E (Playwright) | — | **5/5** | -| Migrations | 0 | **17** | -| Telas SaaS novas | — | 3 | -| Edge functions reescritas | — | 1 (`submit-patient-intake`) | +| auth | 10 | 100% fechado/ok | +| router | 9 | 100% | +| stores | 1 | 100% | +| agenda | 11 | 100% | +| pacientes | 10 | **100% fechado** ✅ | +| seguranca | 1 | 100% | +| saas | 10 | 100% | +| documentos | 10 | **100% fechado** ✅ | +| financeiro | 11 | 5 fechados, 6 médios/baixos adiados | +| comunicacao | 10 | 5 fechados, 5 médios/baixos adiados | +| tenants | 8 | 6 fechados, 2 baixos adiados | +| calendario | 2 | **100% fechado** ✅ | +| addons | 4 | 3 resolvidos, 1 médio adiado | +| central_saas | 3 | 1 alto fechado, 2 médios adiados | +| servicos | 5 | **100% fechado** ✅ | + +**Zero crítico/alto restante no sistema inteiro.** --- -## ⚠️ Adiados (próximas sessões — plano completo no DB) +## ⚠️ Pendências documentadas no DB (não esquecidas) -- **V#3 + V#9 pacientes** — refatoração de composables/services (PatientsCadastroPage 1985 linhas). Sessão dedicada de 1-2h -- **V#50/V#51/V#52 documentos** — portal-paciente policy, hash SHA-256, retention cron -- **Áreas não auditadas:** financeiro, comunicação -- **Deploy real**: cloud Supabase + secrets + edge functions +### A# (1 aberto) +- **A#31 Deploy real** — alto. Reformulação pendente: como ainda não há cloud Supabase nem secrets reais, próxima sessão é "Preparação completa pra deploy" (DEPLOY.md, validar migrations num container limpo, audit de edge functions, listar env vars, script `db.cjs deploy-check`). + +### V# adiados (14) +Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`: +- financeiro (6): parcelamento CHECK, payouts flow, recurrence DELETE policy, composables, máscara PIX, dashboard inadimplência +- comunicacao (5): notifications/schedules silos, email_templates_global filtros, retention notification_logs, dashboard health, audit dismissals/preferences +- tenants (2): owner_users policies, company_profiles + dev_user_credentials +- central_saas (2): rate limit voto, valores tipo_acesso +- addons (1): UI de extrato + +### Outros +- PatientsCadastroPage template breakdown — quando houver E2E +- Pacientes V#9 segue 100% no banco (script foi extraído; template é refator visual separado) diff --git a/database-novo/migrations/20260419000011_documents_portal_patient_policy.sql b/database-novo/migrations/20260419000011_documents_portal_patient_policy.sql new file mode 100644 index 0000000..8e48c63 --- /dev/null +++ b/database-novo/migrations/20260419000011_documents_portal_patient_policy.sql @@ -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.'; diff --git a/database-novo/migrations/20260419000012_documents_content_hash.sql b/database-novo/migrations/20260419000012_documents_content_hash.sql new file mode 100644 index 0000000..c3188d6 --- /dev/null +++ b/database-novo/migrations/20260419000012_documents_content_hash.sql @@ -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.'; diff --git a/database-novo/migrations/20260419000013_cron_retention_jobs.sql b/database-novo/migrations/20260419000013_cron_retention_jobs.sql new file mode 100644 index 0000000..3eb2090 --- /dev/null +++ b/database-novo/migrations/20260419000013_cron_retention_jobs.sql @@ -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())$$ +); diff --git a/database-novo/migrations/20260419000014_financial_security_hardening.sql b/database-novo/migrations/20260419000014_financial_security_hardening.sql new file mode 100644 index 0000000..12fb716 --- /dev/null +++ b/database-novo/migrations/20260419000014_financial_security_hardening.sql @@ -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') + ) + ); diff --git a/database-novo/migrations/20260419000015_communication_security_hardening.sql b/database-novo/migrations/20260419000015_communication_security_hardening.sql new file mode 100644 index 0000000..b72bc6a --- /dev/null +++ b/database-novo/migrations/20260419000015_communication_security_hardening.sql @@ -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()); diff --git a/database-novo/migrations/20260419000016_tenants_calendario_hardening.sql b/database-novo/migrations/20260419000016_tenants_calendario_hardening.sql new file mode 100644 index 0000000..208749c --- /dev/null +++ b/database-novo/migrations/20260419000016_tenants_calendario_hardening.sql @@ -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') + )) + ); diff --git a/database-novo/migrations/20260419000017_addons_central_saas_hardening.sql b/database-novo/migrations/20260419000017_addons_central_saas_hardening.sql new file mode 100644 index 0000000..ed76ed0 --- /dev/null +++ b/database-novo/migrations/20260419000017_addons_central_saas_hardening.sql @@ -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) diff --git a/database-novo/migrations/20260419000018_servicos_prontuarios_hardening.sql b/database-novo/migrations/20260419000018_servicos_prontuarios_hardening.sql new file mode 100644 index 0000000..2206da5 --- /dev/null +++ b/database-novo/migrations/20260419000018_servicos_prontuarios_hardening.sql @@ -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' + ) + ); diff --git a/src/features/patients/PatientsListPage.vue b/src/features/patients/PatientsListPage.vue index 9fd5363..24deb3e 100644 --- a/src/features/patients/PatientsListPage.vue +++ b/src/features/patients/PatientsListPage.vue @@ -16,6 +16,13 @@ -->