Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260417000001_dev_tables
|
||||
-- Área de Desenvolvimento (dev_*) — roadmap, auditoria, concorrentes, logs
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Tabelas usadas pela página /saas/desenvolvimento. Todas restritas a
|
||||
-- saas_admins via RLS (helper public.is_saas_admin()).
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Helper trigger: updated_at
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.dev_set_updated_at()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. dev_roadmap_phases — Fases (1, 2, 3...)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_roadmap_phases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
numero INTEGER NOT NULL UNIQUE,
|
||||
nome VARCHAR(160) NOT NULL,
|
||||
objetivo TEXT,
|
||||
timeline_sugerida VARCHAR(160),
|
||||
criterio_saida TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'planejada'
|
||||
CHECK (status IN ('planejada','em_andamento','concluida','arquivada')),
|
||||
data_inicio DATE,
|
||||
data_fim DATE,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_status ON public.dev_roadmap_phases(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases(ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_roadmap_phases_updated_at ON public.dev_roadmap_phases;
|
||||
CREATE TRIGGER trg_dev_roadmap_phases_updated_at
|
||||
BEFORE UPDATE ON public.dev_roadmap_phases
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. dev_roadmap_items — Itens das fases
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_roadmap_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phase_id BIGINT NOT NULL REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE,
|
||||
numero INTEGER,
|
||||
bloco VARCHAR(160),
|
||||
feature TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
esforco VARCHAR(4)
|
||||
CHECK (esforco IS NULL OR esforco IN ('S','M','L','XL')),
|
||||
prioridade VARCHAR(20)
|
||||
CHECK (prioridade IS NULL OR prioridade IN ('bloqueador','alta','media','diferencial')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
|
||||
CHECK (status IN ('pendente','em_andamento','concluido','cancelado','bloqueado')),
|
||||
notas TEXT,
|
||||
assignee VARCHAR(120),
|
||||
data_inicio DATE,
|
||||
data_conclusao DATE,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_phase ON public.dev_roadmap_items(phase_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_status ON public.dev_roadmap_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_prior ON public.dev_roadmap_items(prioridade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_ordem ON public.dev_roadmap_items(phase_id, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_roadmap_items_updated_at ON public.dev_roadmap_items;
|
||||
CREATE TRIGGER trg_dev_roadmap_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_roadmap_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. dev_auditoria_items — Bugs / débitos técnicos / decisões
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_auditoria_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
categoria VARCHAR(120),
|
||||
titulo TEXT NOT NULL,
|
||||
descricao_problema TEXT,
|
||||
solucao TEXT,
|
||||
severidade VARCHAR(20)
|
||||
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'aberto'
|
||||
CHECK (status IN ('aberto','em_analise','resolvido','wontfix','duplicado')),
|
||||
resolvido_em DATE,
|
||||
sessao_resolucao VARCHAR(160),
|
||||
arquivo_afetado TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_status ON public.dev_auditoria_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_severidade ON public.dev_auditoria_items(severidade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_categoria ON public.dev_auditoria_items(categoria);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_auditoria_items_updated_at ON public.dev_auditoria_items;
|
||||
CREATE TRIGGER trg_dev_auditoria_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_auditoria_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. dev_competitors — Concorrentes
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_competitors (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
slug VARCHAR(80) NOT NULL UNIQUE,
|
||||
nome VARCHAR(160) NOT NULL,
|
||||
pais VARCHAR(40),
|
||||
foco VARCHAR(160),
|
||||
pricing TEXT,
|
||||
posicionamento TEXT,
|
||||
url TEXT,
|
||||
ultima_pesquisa DATE,
|
||||
notas TEXT,
|
||||
ativo BOOLEAN NOT NULL DEFAULT true,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitors_ativo ON public.dev_competitors(ativo);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitors_pais ON public.dev_competitors(pais);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_competitors_updated_at ON public.dev_competitors;
|
||||
CREATE TRIGGER trg_dev_competitors_updated_at
|
||||
BEFORE UPDATE ON public.dev_competitors
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. dev_competitor_features — features de cada concorrente
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_competitor_features (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
|
||||
categoria VARCHAR(120),
|
||||
nome TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
fonte VARCHAR(20) NOT NULL DEFAULT 'publico'
|
||||
CHECK (fonte IN ('fetched','observacao','publico','hipotese')),
|
||||
fonte_url TEXT,
|
||||
data_fonte DATE,
|
||||
destaque BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_comp ON public.dev_competitor_features(competitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_cat ON public.dev_competitor_features(categoria);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_destaque ON public.dev_competitor_features(destaque);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_competitor_features_updated_at ON public.dev_competitor_features;
|
||||
CREATE TRIGGER trg_dev_competitor_features_updated_at
|
||||
BEFORE UPDATE ON public.dev_competitor_features
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. dev_comparison_matrix — AgenciaPsi × features-de-concorrente
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_comparison_matrix (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dominio VARCHAR(120),
|
||||
feature TEXT NOT NULL,
|
||||
nosso_status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
|
||||
CHECK (nosso_status IN ('tem','parcial','gap','na','a_definir')),
|
||||
nossa_nota TEXT,
|
||||
importancia VARCHAR(20)
|
||||
CHECK (importancia IS NULL OR importancia IN ('alta','media','baixa')),
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix(dominio);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_status ON public.dev_comparison_matrix(nosso_status);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_comparison_matrix_updated_at ON public.dev_comparison_matrix;
|
||||
CREATE TRIGGER trg_dev_comparison_matrix_updated_at
|
||||
BEFORE UPDATE ON public.dev_comparison_matrix
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- dev_comparison_competitor_status — opcional: status por concorrente por feature
|
||||
-- (se quisermos marcar que competitor X tem feature Y). Tabela ponte N-N.
|
||||
CREATE TABLE IF NOT EXISTS public.dev_comparison_competitor_status (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
comparison_id BIGINT NOT NULL REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE,
|
||||
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
|
||||
CHECK (status IN ('tem','parcial','gap','na','a_definir')),
|
||||
nota TEXT,
|
||||
fonte VARCHAR(20)
|
||||
CHECK (fonte IS NULL OR fonte IN ('fetched','observacao','publico','hipotese')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (comparison_id, competitor_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comp ON public.dev_comparison_competitor_status(competitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comparison ON public.dev_comparison_competitor_status(comparison_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_ccs_updated_at ON public.dev_comparison_competitor_status;
|
||||
CREATE TRIGGER trg_dev_ccs_updated_at
|
||||
BEFORE UPDATE ON public.dev_comparison_competitor_status
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. dev_generation_log — histórico de execuções (backup, dashboard, export...)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_generation_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tipo VARCHAR(40) NOT NULL,
|
||||
comando TEXT,
|
||||
sucesso BOOLEAN NOT NULL DEFAULT false,
|
||||
stdout TEXT,
|
||||
stderr TEXT,
|
||||
duration_ms INTEGER,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
trigger_user_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_tipo ON public.dev_generation_log(tipo);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_created ON public.dev_generation_log(created_at DESC);
|
||||
|
||||
-- =============================================================================
|
||||
-- RLS — tudo restrito a saas_admins (helper existente: public.is_saas_admin())
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
t TEXT;
|
||||
dev_tables TEXT[] := ARRAY[
|
||||
'dev_roadmap_phases',
|
||||
'dev_roadmap_items',
|
||||
'dev_auditoria_items',
|
||||
'dev_competitors',
|
||||
'dev_competitor_features',
|
||||
'dev_comparison_matrix',
|
||||
'dev_comparison_competitor_status',
|
||||
'dev_generation_log'
|
||||
];
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY dev_tables
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY;', t);
|
||||
|
||||
-- Drop policy se existir (idempotente)
|
||||
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I;', t || '_saas_admin_all', t);
|
||||
|
||||
-- Cria policy que permite tudo pra saas_admin
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON public.%I FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());',
|
||||
t || '_saas_admin_all',
|
||||
t
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Comentários
|
||||
-- =============================================================================
|
||||
COMMENT ON TABLE public.dev_roadmap_phases IS 'Fases do roadmap (MVP, Paridade, Diferenciação). Visível só pra saas_admins.';
|
||||
COMMENT ON TABLE public.dev_roadmap_items IS 'Itens de cada fase do roadmap.';
|
||||
COMMENT ON TABLE public.dev_auditoria_items IS 'Bugs, dívidas técnicas e decisões arquiteturais.';
|
||||
COMMENT ON TABLE public.dev_competitors IS 'Concorrentes analisados no benchmark.';
|
||||
COMMENT ON TABLE public.dev_competitor_features IS 'Features catalogadas de cada concorrente.';
|
||||
COMMENT ON TABLE public.dev_comparison_matrix IS 'Matriz de comparação AgenciaPsi × features esperadas do mercado.';
|
||||
COMMENT ON TABLE public.dev_comparison_competitor_status IS 'Qual concorrente tem qual feature (ponte N-N com matrix).';
|
||||
COMMENT ON TABLE public.dev_generation_log IS 'Histórico de execuções (backup, dashboard, export, seed, etc).';
|
||||
@@ -0,0 +1,48 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260417000002_dev_tables_ordem
|
||||
-- Adiciona coluna `ordem` em dev_auditoria_items e dev_competitor_features
|
||||
-- (pra suportar reordenação por drag-and-drop na UI).
|
||||
-- =============================================================================
|
||||
|
||||
-- dev_auditoria_items
|
||||
ALTER TABLE public.dev_auditoria_items
|
||||
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_ordem ON public.dev_auditoria_items(ordem);
|
||||
|
||||
-- Popular ordem existente (status + id pra evitar colisão)
|
||||
UPDATE public.dev_auditoria_items SET ordem = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'aberto' THEN 1
|
||||
WHEN 'em_analise' THEN 2
|
||||
WHEN 'resolvido' THEN 3
|
||||
WHEN 'wontfix' THEN 4
|
||||
WHEN 'duplicado' THEN 5
|
||||
ELSE 6
|
||||
END,
|
||||
id
|
||||
) AS rn
|
||||
FROM public.dev_auditoria_items
|
||||
) sub
|
||||
WHERE public.dev_auditoria_items.id = sub.id;
|
||||
|
||||
-- dev_competitor_features
|
||||
ALTER TABLE public.dev_competitor_features
|
||||
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_ordem
|
||||
ON public.dev_competitor_features(competitor_id, ordem);
|
||||
|
||||
-- Popular ordem existente (por competitor + categoria + id)
|
||||
UPDATE public.dev_competitor_features SET ordem = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY competitor_id
|
||||
ORDER BY COALESCE(categoria, 'zzz'), id
|
||||
) AS rn
|
||||
FROM public.dev_competitor_features
|
||||
) sub
|
||||
WHERE public.dev_competitor_features.id = sub.id;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000001_dev_verificacoes
|
||||
-- Nova aba "Verificações" em /saas/desenvolvimento
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Diferente de dev_auditoria_items (bugs conhecidos), esta tabela registra o
|
||||
-- PROCESSO de revisão sênior sessão-a-sessão: o que já foi olhado, o que falta
|
||||
-- olhar, o que foi encontrado em cada área do sistema.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.dev_verificacoes_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area VARCHAR(80) NOT NULL,
|
||||
categoria VARCHAR(120),
|
||||
titulo TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
resultado TEXT,
|
||||
acao_sugerida TEXT,
|
||||
severidade VARCHAR(20)
|
||||
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
|
||||
CHECK (status IN ('pendente','verificando','ok','problema','corrigido','wontfix')),
|
||||
verificado_em DATE,
|
||||
sessao_verificacao VARCHAR(160),
|
||||
arquivo_afetado TEXT,
|
||||
auditoria_item_id BIGINT REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_area ON public.dev_verificacoes_items(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_status ON public.dev_verificacoes_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_severidade ON public.dev_verificacoes_items(severidade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_ordem ON public.dev_verificacoes_items(area, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_verificacoes_updated_at ON public.dev_verificacoes_items;
|
||||
CREATE TRIGGER trg_dev_verificacoes_updated_at
|
||||
BEFORE UPDATE ON public.dev_verificacoes_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items;
|
||||
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.dev_verificacoes_items IS 'Revisão sênior por área/sessão — o que foi verificado e o que foi encontrado.';
|
||||
COMMENT ON COLUMN public.dev_verificacoes_items.area IS 'Domínio revisado: auth, router, agenda, financeiro, pacientes, comunicacao, etc.';
|
||||
COMMENT ON COLUMN public.dev_verificacoes_items.auditoria_item_id IS 'Link opcional: se a verificação virou um bug em dev_auditoria_items.';
|
||||
@@ -0,0 +1,403 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000002_patient_intake_security_hardening
|
||||
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
|
||||
-- avatars + storage policies.
|
||||
--
|
||||
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
|
||||
-- whitelist de valores, validação de token completa (active/expires/max_uses).
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. create_patient_intake_request_v2 — versão hardened
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Mudanças vs versão anterior:
|
||||
-- • A#16: valida active, expires_at, max_uses; incrementa uses no final
|
||||
-- • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
|
||||
-- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
|
||||
-- • A#27: length checks em TODOS os campos texto
|
||||
-- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
|
||||
-- lower em emails, whitelist para genero/estado_civil
|
||||
-- • Consent obrigatório (raise se false)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
|
||||
p_token text,
|
||||
p_payload jsonb
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_active boolean;
|
||||
v_expires timestamptz;
|
||||
v_max_uses int;
|
||||
v_uses int;
|
||||
v_intake_id uuid;
|
||||
v_birth_raw text;
|
||||
v_birth date;
|
||||
v_email text;
|
||||
v_email_alt text;
|
||||
v_nome text;
|
||||
v_consent boolean;
|
||||
v_genero text;
|
||||
v_estado_civil text;
|
||||
|
||||
-- Whitelists para campos tipados
|
||||
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
|
||||
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
|
||||
BEGIN
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Carrega invite e valida TUDO (A#16)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
|
||||
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
|
||||
FROM public.patient_invites
|
||||
WHERE token = p_token
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_active IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_expires IS NOT NULL AND now() > v_expires THEN
|
||||
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
|
||||
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Resolver tenant_id (A#19)
|
||||
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_tenant_id IS NULL THEN
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_owner_id
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização + validações de campos (A#27)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Nome obrigatório (max 200)
|
||||
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
|
||||
IF v_nome IS NULL THEN
|
||||
RAISE EXCEPTION 'Nome é obrigatório';
|
||||
END IF;
|
||||
IF length(v_nome) > 200 THEN
|
||||
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
|
||||
END IF;
|
||||
|
||||
-- Email principal obrigatório + lower + max 120
|
||||
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
|
||||
IF v_email IS NULL THEN
|
||||
RAISE EXCEPTION 'E-mail é obrigatório';
|
||||
END IF;
|
||||
IF length(v_email) > 120 THEN
|
||||
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
|
||||
END IF;
|
||||
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
RAISE EXCEPTION 'E-mail inválido';
|
||||
END IF;
|
||||
|
||||
-- Email alternativo opcional mas validado se presente
|
||||
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
|
||||
IF v_email_alt IS NOT NULL THEN
|
||||
IF length(v_email_alt) > 120 THEN
|
||||
RAISE EXCEPTION 'E-mail alternativo muito longo';
|
||||
END IF;
|
||||
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
RAISE EXCEPTION 'E-mail alternativo inválido';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Consent obrigatório
|
||||
v_consent := coalesce((p_payload->>'consent')::boolean, false);
|
||||
IF v_consent IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Consentimento é obrigatório';
|
||||
END IF;
|
||||
|
||||
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
|
||||
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
|
||||
v_birth := CASE
|
||||
WHEN v_birth_raw IS NULL THEN NULL
|
||||
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
|
||||
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
|
||||
ELSE NULL
|
||||
END;
|
||||
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
|
||||
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
|
||||
v_birth := NULL;
|
||||
END IF;
|
||||
|
||||
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
|
||||
v_genero := nullif(trim(p_payload->>'genero'), '');
|
||||
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
|
||||
v_genero := NULL;
|
||||
END IF;
|
||||
|
||||
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
|
||||
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
|
||||
v_estado_civil := NULL;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- INSERT com sanitização inline
|
||||
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
|
||||
-- do terapeuta, não deve vir do paciente.
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.patient_intake_requests (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
token,
|
||||
status,
|
||||
consent,
|
||||
|
||||
nome_completo,
|
||||
email_principal,
|
||||
email_alternativo,
|
||||
telefone,
|
||||
telefone_alternativo,
|
||||
|
||||
avatar_url,
|
||||
|
||||
data_nascimento,
|
||||
cpf,
|
||||
rg,
|
||||
genero,
|
||||
estado_civil,
|
||||
profissao,
|
||||
escolaridade,
|
||||
nacionalidade,
|
||||
naturalidade,
|
||||
|
||||
cep,
|
||||
pais,
|
||||
cidade,
|
||||
estado,
|
||||
endereco,
|
||||
numero,
|
||||
complemento,
|
||||
bairro,
|
||||
|
||||
observacoes,
|
||||
|
||||
encaminhado_por,
|
||||
onde_nos_conheceu
|
||||
)
|
||||
VALUES (
|
||||
v_owner_id,
|
||||
v_tenant_id,
|
||||
p_token,
|
||||
'new',
|
||||
v_consent,
|
||||
|
||||
v_nome,
|
||||
v_email,
|
||||
v_email_alt,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
|
||||
|
||||
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
|
||||
|
||||
v_birth,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'rg'), ''), 20),
|
||||
v_genero,
|
||||
v_estado_civil,
|
||||
left(nullif(trim(p_payload->>'profissao'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
|
||||
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
|
||||
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'pais'), ''), 60),
|
||||
left(nullif(trim(p_payload->>'cidade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'estado'), ''), 2),
|
||||
left(nullif(trim(p_payload->>'endereco'), ''), 200),
|
||||
left(nullif(trim(p_payload->>'numero'), ''), 20),
|
||||
left(nullif(trim(p_payload->>'complemento'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'bairro'), ''), 120),
|
||||
|
||||
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
|
||||
|
||||
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
|
||||
)
|
||||
RETURNING id INTO v_intake_id;
|
||||
|
||||
-- Incrementa contador de uso (A#16)
|
||||
UPDATE public.patient_invites
|
||||
SET uses = uses + 1
|
||||
WHERE token = p_token;
|
||||
|
||||
RETURN v_intake_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) IS
|
||||
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas (campo interno); exige consent=true.';
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
|
||||
-- Novo: gera gen_random_uuid() server-side e retorna.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_tenant_id uuid;
|
||||
v_new_token text;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
|
||||
v_new_token := replace(gen_random_uuid()::text, '-', '');
|
||||
|
||||
-- Resolve tenant_id do usuário (active)
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_uid
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- Desativa tokens ativos anteriores
|
||||
UPDATE public.patient_invites
|
||||
SET active = false
|
||||
WHERE owner_id = v_uid
|
||||
AND active = true;
|
||||
|
||||
-- Insere novo
|
||||
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
|
||||
VALUES (v_uid, v_tenant_id, v_new_token, true);
|
||||
|
||||
RETURN v_new_token;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
|
||||
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Substitui o client-side newToken() + direct insert em patient_invites.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_tenant_id uuid;
|
||||
v_token text;
|
||||
v_existing text;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
|
||||
SELECT token
|
||||
INTO v_existing
|
||||
FROM public.patient_invites
|
||||
WHERE owner_id = v_uid
|
||||
AND active = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN v_existing;
|
||||
END IF;
|
||||
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_uid
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
v_token := replace(gen_random_uuid()::text, '-', '');
|
||||
|
||||
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
|
||||
VALUES (v_uid, v_tenant_id, v_token, true);
|
||||
|
||||
RETURN v_token;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.issue_patient_invite() IS
|
||||
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
|
||||
-- -----------------------------------------------------------------------------
|
||||
UPDATE storage.buckets
|
||||
SET file_size_limit = 5242880, -- 5 MB
|
||||
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
|
||||
WHERE id = 'avatars';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 5. Storage policies — remover upload anon irrestrito (A#15)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
|
||||
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
|
||||
-- arquivo. Removemos essas policies. Upload público passa a exigir token
|
||||
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
|
||||
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;
|
||||
|
||||
-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
|
||||
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
|
||||
-- o terapeuta vê no painel de cadastros recebidos).
|
||||
CREATE POLICY "intake_read_owner_only"
|
||||
ON storage.objects FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'avatars'
|
||||
AND (storage.foldername(name))[1] = 'intakes'
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
|
||||
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';
|
||||
@@ -0,0 +1,280 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000003_patient_invite_attempts_log
|
||||
-- Resolve A#24: log de tentativas de submit no cadastro público externo.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Observação sobre IP: em RPC Postgres chamada via PostgREST o IP real do
|
||||
-- cliente não chega aqui (só o do connection pooler). Por isso o registro
|
||||
-- guarda o user_agent enviado pelo cliente (quando disponível) + metadados
|
||||
-- resolvidos (owner, tenant). Rate-limit real por IP deve ser feito em edge
|
||||
-- function no futuro (A#20).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_invite_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token text NOT NULL,
|
||||
ok boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
client_info text, -- user_agent enviado pelo cliente (cap 500 no INSERT)
|
||||
owner_id uuid, -- resolvido do token quando possível
|
||||
tenant_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_created ON public.patient_invite_attempts(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_token ON public.patient_invite_attempts(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_owner ON public.patient_invite_attempts(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_ok ON public.patient_invite_attempts(ok) WHERE ok = false;
|
||||
|
||||
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner vê suas próprias tentativas (qualquer flood/erro que envolveu seus links)
|
||||
DROP POLICY IF EXISTS patient_invite_attempts_owner_read ON public.patient_invite_attempts;
|
||||
CREATE POLICY patient_invite_attempts_owner_read
|
||||
ON public.patient_invite_attempts FOR SELECT
|
||||
TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.patient_invite_attempts IS
|
||||
'Log de tentativas (ok e falhas) de submit do form público de cadastro externo. Base para monitoramento de flood/tentativas maliciosas. Sem IP direto — proteção LGPD.';
|
||||
|
||||
COMMENT ON COLUMN public.patient_invite_attempts.client_info IS
|
||||
'User-agent enviado pelo cliente (opcional). Limitado a 500 chars no insert. Não contém PII.';
|
||||
|
||||
-- =============================================================================
|
||||
-- create_patient_intake_request_v2 — versão instrumentada
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Mesma função do hardening anterior, agora com log em patient_invite_attempts.
|
||||
-- O log é feito num bloco EXCEPTION que NUNCA propaga falha de log pro fluxo
|
||||
-- principal (log falhar jamais deve impedir o cadastro de ser aceito).
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
|
||||
p_token text,
|
||||
p_payload jsonb,
|
||||
p_client_info text DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_active boolean;
|
||||
v_expires timestamptz;
|
||||
v_max_uses int;
|
||||
v_uses int;
|
||||
v_intake_id uuid;
|
||||
v_birth_raw text;
|
||||
v_birth date;
|
||||
v_email text;
|
||||
v_email_alt text;
|
||||
v_nome text;
|
||||
v_consent boolean;
|
||||
v_genero text;
|
||||
v_estado_civil text;
|
||||
v_err_msg text;
|
||||
v_err_code text;
|
||||
v_clean_info text;
|
||||
|
||||
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
|
||||
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
|
||||
|
||||
-- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
|
||||
-- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
|
||||
BEGIN
|
||||
-- Sanitiza client_info recebido (cap + trim)
|
||||
v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Resolve invite + valida TUDO (A#16)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
|
||||
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
|
||||
FROM public.patient_invites
|
||||
WHERE token = p_token
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
v_err_code := 'TOKEN_INVALID';
|
||||
v_err_msg := 'Token inválido';
|
||||
-- Log + raise (owner_id NULL porque token não bateu)
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_active IS NOT TRUE THEN
|
||||
v_err_code := 'TOKEN_DISABLED';
|
||||
v_err_msg := 'Link desativado';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_expires IS NOT NULL AND now() > v_expires THEN
|
||||
v_err_code := 'TOKEN_EXPIRED';
|
||||
v_err_msg := 'Link expirado';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
|
||||
v_err_code := 'TOKEN_MAX_USES';
|
||||
v_err_msg := 'Limite de uso atingido';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Resolve tenant_id se invite não tiver (A#19)
|
||||
IF v_tenant_id IS NULL THEN
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_owner_id
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização + validações de campos (A#27)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
|
||||
IF v_nome IS NULL THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF length(v_nome) > 200 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
|
||||
IF v_email IS NULL THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF length(v_email) > 120 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
|
||||
IF v_email_alt IS NOT NULL THEN
|
||||
IF length(v_email_alt) > 120 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_consent := coalesce((p_payload->>'consent')::boolean, false);
|
||||
IF v_consent IS NOT TRUE THEN
|
||||
v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
|
||||
v_birth := CASE
|
||||
WHEN v_birth_raw IS NULL THEN NULL
|
||||
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
|
||||
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
|
||||
ELSE NULL
|
||||
END;
|
||||
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
|
||||
v_birth := NULL;
|
||||
END IF;
|
||||
|
||||
v_genero := nullif(trim(p_payload->>'genero'), '');
|
||||
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
|
||||
v_genero := NULL;
|
||||
END IF;
|
||||
|
||||
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
|
||||
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
|
||||
v_estado_civil := NULL;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- INSERT
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.patient_intake_requests (
|
||||
owner_id, tenant_id, token, status, consent,
|
||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||
avatar_url,
|
||||
data_nascimento, cpf, rg, genero, estado_civil,
|
||||
profissao, escolaridade, nacionalidade, naturalidade,
|
||||
cep, pais, cidade, estado, endereco, numero, complemento, bairro,
|
||||
observacoes, encaminhado_por, onde_nos_conheceu
|
||||
)
|
||||
VALUES (
|
||||
v_owner_id, v_tenant_id, p_token, 'new', v_consent,
|
||||
v_nome, v_email, v_email_alt,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
|
||||
v_birth,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'rg'), ''), 20),
|
||||
v_genero, v_estado_civil,
|
||||
left(nullif(trim(p_payload->>'profissao'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
|
||||
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'pais'), ''), 60),
|
||||
left(nullif(trim(p_payload->>'cidade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'estado'), ''), 2),
|
||||
left(nullif(trim(p_payload->>'endereco'), ''), 200),
|
||||
left(nullif(trim(p_payload->>'numero'), ''), 20),
|
||||
left(nullif(trim(p_payload->>'complemento'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'bairro'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
|
||||
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
|
||||
)
|
||||
RETURNING id INTO v_intake_id;
|
||||
|
||||
UPDATE public.patient_invites
|
||||
SET uses = uses + 1
|
||||
WHERE token = p_token;
|
||||
|
||||
-- Log de sucesso (best-effort, não propaga erro)
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
|
||||
RETURN v_intake_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) IS
|
||||
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas; exige consent=true; registra cada tentativa em patient_invite_attempts (A#24).';
|
||||
@@ -0,0 +1,149 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000004_dev_tests
|
||||
-- Nova aba "Testes" em /saas/desenvolvimento — catálogo de suítes de teste.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Espelha a estrutura de dev_verificacoes_items. Uma linha = uma suíte de
|
||||
-- teste (arquivo .spec.js ou grupo de testes). Serve para responder "quais
|
||||
-- áreas estão cobertas por teste?" sem rodar npm test.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.dev_test_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area VARCHAR(80) NOT NULL,
|
||||
categoria VARCHAR(120), -- unit, integration, e2e, manual
|
||||
titulo TEXT NOT NULL,
|
||||
arquivo TEXT,
|
||||
descricao TEXT,
|
||||
total_tests INTEGER DEFAULT 0,
|
||||
passing INTEGER DEFAULT 0,
|
||||
failing INTEGER DEFAULT 0,
|
||||
skipped INTEGER DEFAULT 0,
|
||||
cobertura_pct NUMERIC(5,2), -- cobertura estimada daquela área
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ok'
|
||||
CHECK (status IN ('ok','falhando','pendente','obsoleto','a_escrever')),
|
||||
last_run_at TIMESTAMPTZ,
|
||||
sessao_criacao VARCHAR(160),
|
||||
notas TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_area ON public.dev_test_items(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_status ON public.dev_test_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_ordem ON public.dev_test_items(area, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_test_items_updated_at ON public.dev_test_items;
|
||||
CREATE TRIGGER trg_dev_test_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_test_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS dev_test_items_saas_admin_all ON public.dev_test_items;
|
||||
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.dev_test_items IS
|
||||
'Catálogo de suítes de teste por área. Responde "o que está testado?" sem precisar rodar npm test.';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- Seed inicial — testes existentes em 2026-04-18
|
||||
-- =============================================================================
|
||||
INSERT INTO public.dev_test_items
|
||||
(area, categoria, titulo, arquivo, descricao, total_tests, passing, failing, skipped, cobertura_pct, status, last_run_at, sessao_criacao, notas, tags, ordem)
|
||||
VALUES
|
||||
('agenda', 'unit',
|
||||
'useRecurrence — geração de ocorrências',
|
||||
'src/features/agenda/composables/__tests__/useRecurrence.spec.js',
|
||||
$$Cobre: generateDates (weekly, biweekly, custom_weekdays, monthly, yearly), expandRules com exceções (cancel_session, patient_missed, reschedule_session, holiday_block), mergeWithStoredSessions, max_occurrences, range boundaries, remarcação inbound.$$,
|
||||
23, 23, 0, 0, NULL,
|
||||
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
|
||||
'Suite sólida. Cobre os branches críticos da expansão de recorrência. Testes sobreviveram à adição do cap de range (V#20) e ao filtro de tenant_id nas CRUDs (V#12).',
|
||||
ARRAY['unit','agenda','recurrence','critical'], 1),
|
||||
|
||||
('agenda', 'unit',
|
||||
'agendaMappers — transformação pra FullCalendar',
|
||||
'src/features/agenda/services/__tests__/agendaMappers.spec.js',
|
||||
$$Cobre: mapAgendaEventosToCalendarEvents (shape, campos extras), status → cor + ícone (agendado, realizado, faltou, cancelado, remarcado), aliases de FK (patients, determined_commitments), tipo fallback, ocorrência virtual (is_occurrence), resource events (clinic mosaic).$$,
|
||||
40, 40, 0, 0, NULL,
|
||||
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
|
||||
'Quatro testes estavam falhando antes do V#21 (status "remarcado" vs "remarcar" + cores faltou/cancelado invertidas). Agora 100%.',
|
||||
ARRAY['unit','agenda','mappers'], 2),
|
||||
|
||||
('auth', 'a_escrever',
|
||||
'guards.js — branches do router beforeEach',
|
||||
'src/router/__tests__/guards.spec.js (não existe)',
|
||||
$$Deveria cobrir: rotas públicas liberadas, redirect pra /auth/login sem session, área /account sem tenant, saas_admin só em /saas, tenant lockdown, trocaTenantScope, matchesRoles com aliases, cache de globalRole, cache de saasAdmin.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'guards.js tem ~650 linhas e só roda via navegação real. Sem teste unitário → mudanças no guard são de alto risco. Prioridade média para criar (mock do router + pinia).',
|
||||
ARRAY['unit','auth','router','guard','missing'], 3),
|
||||
|
||||
('auth', 'a_escrever',
|
||||
'session.js — hydrate e race conditions',
|
||||
'src/app/__tests__/session.spec.js (não existe)',
|
||||
$$Deveria cobrir: initSession com/sem session, refreshSession que não dispara se refreshing, SIGNED_IN redundante ignorado, SIGNED_OUT zera state, TOKEN_REFRESHED não derruba cache, hydrate preserva user em erro.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'Módulo tem histórico de race conditions (comentado no próprio arquivo). Teste unitário daria garantia contra regressão.',
|
||||
ARRAY['unit','auth','session','race','missing'], 4),
|
||||
|
||||
('stores', 'a_escrever',
|
||||
'tenantStore — singleflight + persist',
|
||||
'src/stores/__tests__/tenantStore.spec.js (não existe)',
|
||||
$$Deveria cobrir: loadSessionAndTenant com Promise compartilhada (V#3), ensureLoaded sem setInterval, tenant salvo só se pertence ao user, normalizeTenantRole, reset, persistência em localStorage.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'V#3 trocou polling por Promise singleflight — a correção não tem teste que proteja contra regressão.',
|
||||
ARRAY['unit','store','tenant','missing'], 5),
|
||||
|
||||
('utils', 'a_escrever',
|
||||
'roleNormalizer — saídas esperadas',
|
||||
'src/utils/__tests__/roleNormalizer.spec.js (não existe)',
|
||||
$$Fácil de testar — função pura, sem IO. Cobre: tenant_admin+therapist→therapist, tenant_admin+clinic→clinic_admin, tenant_admin+supervisor→supervisor, tenant_admin sem kind→clinic_admin, clinic_admin→clinic_admin, pass-through.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'Criado em V#4. É função pura — fácil de cobrir em 10min. Baixa prioridade técnica mas alto valor simbólico (garantir que os 2 consumidores — guards.js e tenantStore.js — concordam).',
|
||||
ARRAY['unit','utils','trivial'], 6),
|
||||
|
||||
('pacientes', 'a_escrever',
|
||||
'Cadastros externos — fluxo do paciente',
|
||||
'src/features/patients/__tests__/external-intake.spec.js (não existe)',
|
||||
$$Deveria cobrir: validação client-side (token regex, email, consent), truncation em todos os campos, payload final, não envio de notas_internas, comportamento com token inválido.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Página pública é ponto crítico de segurança. Teste de regressão importante após A#17/A#18/A#21 — garantir que nenhum dos valores "perigosos" voltem a ser enviados.',
|
||||
ARRAY['unit','pacientes','external','security-regression'], 7),
|
||||
|
||||
('database', 'manual',
|
||||
'RPCs de intake — validação de inputs maliciosos',
|
||||
'database-novo/tests/test_patient_intake_security.sql (sugerido)',
|
||||
$$Deveria cobrir: token inválido raise, token desativado raise (A#16), token expirado raise, max_uses raise, uses incrementa após sucesso, consent=false raise, payload com notas_internas é ignorado (A#17), tenant_id é preenchido (A#19), nome > 200 chars raise, email inválido raise, genero fora whitelist vira NULL, data_nascimento futura vira NULL.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Testes SQL diretos via psql. Importantes porque as validações estão dentro do RPC SECURITY DEFINER. Executar antes de cada deploy.',
|
||||
ARRAY['manual','sql','security','rpc'], 8),
|
||||
|
||||
('agenda', 'a_escrever',
|
||||
'useAgendaEvents — wrapper do repository',
|
||||
'src/features/agenda/composables/__tests__/useAgendaEvents.spec.js (não existe)',
|
||||
$$Deveria cobrir: loadMyRange chama listMyAgendaEvents, estado loading/error transições, sem ownerId retorna cedo, rollback em erro.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 2 — agenda',
|
||||
'Após refactor V#14 o composable virou fino. Teste garante que continue fino.',
|
||||
ARRAY['unit','agenda','composable','missing'], 9),
|
||||
|
||||
('e2e', 'a_escrever',
|
||||
'Fluxo completo: terapeuta cria link → paciente preenche → terapeuta vê',
|
||||
'(não existe)',
|
||||
$$Deveria cobrir o happy path integrado: login terapeuta, gera link via issue_patient_invite, abre /cadastro/paciente em aba anônima, preenche, submit, terapeuta vê em /therapist/patients/recebidos.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Não há E2E hoje. Playwright ou Cypress valem? Decidir provider. Alta prioridade pra confiança em deploy.',
|
||||
ARRAY['e2e','critical','missing','decisão-pendente'], 10);
|
||||
|
||||
SELECT id, area, categoria, status, total_tests, passing FROM public.dev_test_items ORDER BY ordem;
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000005_saas_rls_emergency_fix
|
||||
-- Corrige A#30 (P0) — 7 tabelas SaaS estavam com RLS desabilitado + grants
|
||||
-- totais pra anon/authenticated/service_role. Qualquer usuário anônimo
|
||||
-- podia alterar/deletar dados críticos (tenant_features, plan_prices,
|
||||
-- subscription_intents_personal/tenant, plan_public, ...).
|
||||
--
|
||||
-- Estratégia:
|
||||
-- 1. Habilitar RLS em todas as 7 tabelas
|
||||
-- 2. REVOKE ALL de anon (nunca deveria ter tido)
|
||||
-- 3. REVOKE ALL de authenticated (controle passa a ser via policy)
|
||||
-- 4. Policies explícitas por caso de uso
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. REVOKE grants inseguros
|
||||
-- -----------------------------------------------------------------------------
|
||||
REVOKE ALL ON public.tenant_features FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_prices FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_public FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_public_bullets FROM anon, authenticated;
|
||||
REVOKE ALL ON public.subscription_intents_personal FROM anon, authenticated;
|
||||
REVOKE ALL ON public.subscription_intents_tenant FROM anon, authenticated;
|
||||
REVOKE ALL ON public.tenant_feature_exceptions_log FROM anon, authenticated;
|
||||
|
||||
-- Concede o mínimo necessário (controlado por RLS abaixo)
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_features TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_prices TO authenticated;
|
||||
GRANT SELECT ON public.plan_public TO anon, authenticated;
|
||||
GRANT INSERT, UPDATE, DELETE ON public.plan_public TO authenticated;
|
||||
GRANT SELECT ON public.plan_public_bullets TO anon, authenticated;
|
||||
GRANT INSERT, UPDATE, DELETE ON public.plan_public_bullets TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_personal TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_tenant TO authenticated;
|
||||
GRANT SELECT ON public.tenant_feature_exceptions_log TO authenticated;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. HABILITAR RLS em todas
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. POLICIES — tenant_features
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- SELECT: membros do tenant leem as features do próprio tenant. Saas admin lê tudo.
|
||||
DROP POLICY IF EXISTS tenant_features_select ON public.tenant_features;
|
||||
CREATE POLICY tenant_features_select ON public.tenant_features
|
||||
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')
|
||||
);
|
||||
|
||||
-- WRITE: apenas tenant_admin do próprio tenant OU saas_admin.
|
||||
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
|
||||
CREATE POLICY tenant_features_write ON public.tenant_features
|
||||
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')
|
||||
)
|
||||
)
|
||||
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')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. POLICIES — plan_prices (SaaS admin only pra escrita; authenticated lê)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS plan_prices_read ON public.plan_prices;
|
||||
CREATE POLICY plan_prices_read ON public.plan_prices
|
||||
FOR SELECT TO authenticated
|
||||
USING (true); -- preços são públicos pra usuários logados
|
||||
|
||||
DROP POLICY IF EXISTS plan_prices_write ON public.plan_prices;
|
||||
CREATE POLICY plan_prices_write ON public.plan_prices
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 5. POLICIES — plan_public + plan_public_bullets (anon pode ler — landing page)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS plan_public_read_anon ON public.plan_public;
|
||||
CREATE POLICY plan_public_read_anon ON public.plan_public
|
||||
FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_write ON public.plan_public;
|
||||
CREATE POLICY plan_public_write ON public.plan_public
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_bullets_read_anon ON public.plan_public_bullets;
|
||||
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets
|
||||
FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_bullets_write ON public.plan_public_bullets;
|
||||
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 6. POLICIES — subscription_intents_personal + _tenant
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Dono vê o próprio intent; saas admin vê tudo; owner cria/atualiza seus próprios.
|
||||
DROP POLICY IF EXISTS subscription_intents_personal_owner ON public.subscription_intents_personal;
|
||||
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal
|
||||
FOR ALL TO authenticated
|
||||
USING (user_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (user_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS subscription_intents_tenant_member ON public.subscription_intents_tenant;
|
||||
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_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')
|
||||
)
|
||||
)
|
||||
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')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 7. POLICY — tenant_feature_exceptions_log (somente leitura)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Log de auditoria. Inserts vêm de triggers/funções server-side (SECURITY DEFINER).
|
||||
DROP POLICY IF EXISTS tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log;
|
||||
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log
|
||||
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')
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.tenant_features IS
|
||||
'Controle de features por tenant. RLS: member do tenant lê; tenant_admin ou saas_admin escreve. Antes da migration 20260418000005 estava com RLS off + GRANT ALL pra anon (A#30).';
|
||||
@@ -0,0 +1,214 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000001_tenant_features_b2_governance
|
||||
-- Resolve V#34 (isEnabled opt-out por padrão) + V#41 (dupla fonte entitlements
|
||||
-- vs tenant_features) — Opção B2 (plano + override com exceção comercial).
|
||||
--
|
||||
-- Mudanças:
|
||||
-- 1. Trigger tenant_features_guard_with_plan ganha bypass via session flag
|
||||
-- (current_setting('app.allow_feature_exception')) — só RPC pode setar.
|
||||
-- 2. Nova RPC set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)
|
||||
-- SECURITY DEFINER, com regras assimétricas:
|
||||
-- - p_enabled=false → tenant_admin OU saas_admin (preferência)
|
||||
-- - p_enabled=true AND plano permite → tenant_admin OU saas_admin
|
||||
-- - p_enabled=true AND plano NÃO permite → SOMENTE saas_admin + reason obrigatório
|
||||
-- Toda mudança grava em tenant_feature_exceptions_log.
|
||||
-- 3. Policy tenant_features_write restringida a saas_admin (writes diretos).
|
||||
-- Tenant_admin agora muda só via RPC.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Trigger: bypass controlado por session flag
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.tenant_features_guard_with_plan()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_allowed boolean;
|
||||
v_bypass text;
|
||||
BEGIN
|
||||
-- Só valida quando está habilitando
|
||||
IF new.enabled IS DISTINCT FROM true THEN
|
||||
RETURN new;
|
||||
END IF;
|
||||
|
||||
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
|
||||
-- após validar que o caller é saas_admin com reason.
|
||||
v_bypass := current_setting('app.allow_feature_exception', true);
|
||||
IF v_bypass = 'true' THEN
|
||||
RETURN new;
|
||||
END IF;
|
||||
|
||||
-- Permitido pelo plano do tenant?
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.v_tenant_entitlements_full v
|
||||
WHERE v.tenant_id = new.tenant_id
|
||||
AND v.feature_key = new.feature_key
|
||||
AND v.allowed = true
|
||||
) INTO v_allowed;
|
||||
|
||||
IF NOT v_allowed THEN
|
||||
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
|
||||
new.feature_key, new.tenant_id
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. RPC set_tenant_feature_exception
|
||||
-- (substitui versão anterior que retornava void; retorna jsonb agora)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP FUNCTION IF EXISTS public.set_tenant_feature_exception(uuid, text, boolean, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_tenant_feature_exception(
|
||||
p_tenant_id uuid,
|
||||
p_feature_key text,
|
||||
p_enabled boolean,
|
||||
p_reason text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller uuid := auth.uid();
|
||||
v_is_saas boolean := public.is_saas_admin();
|
||||
v_is_tenant_adm boolean;
|
||||
v_plan_allows boolean;
|
||||
v_feature_key text;
|
||||
v_reason text;
|
||||
v_is_exception boolean;
|
||||
BEGIN
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização (padrão V#31)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
IF p_enabled IS NULL THEN
|
||||
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
|
||||
IF v_feature_key IS NULL THEN
|
||||
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF length(v_feature_key) > 80 THEN
|
||||
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
|
||||
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
|
||||
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
|
||||
v_reason := substring(v_reason FROM 1 FOR 500);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
|
||||
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Plano permite essa feature?
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.v_tenant_entitlements vte
|
||||
WHERE vte.tenant_id = p_tenant_id
|
||||
AND vte.feature_key = v_feature_key
|
||||
) INTO v_plan_allows;
|
||||
|
||||
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Caller é tenant_admin desse tenant?
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
v_is_tenant_adm := EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = p_tenant_id
|
||||
AND tm.user_id = v_caller
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Autorização (assimétrica — V#34 Opção B2)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_is_exception THEN
|
||||
-- Override positivo fora do plano = exceção comercial
|
||||
IF NOT v_is_saas THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF v_reason IS NULL THEN
|
||||
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
ELSE
|
||||
-- Demais casos: tenant_admin OR saas_admin
|
||||
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
|
||||
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Persistência: bypass controlado do trigger guard quando é exceção
|
||||
-- (escopo de transação via SET LOCAL — só esta RPC vê)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_is_exception THEN
|
||||
PERFORM set_config('app.allow_feature_exception', 'true', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
|
||||
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
|
||||
ON CONFLICT (tenant_id, feature_key)
|
||||
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
|
||||
|
||||
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
|
||||
IF v_is_exception THEN
|
||||
PERFORM set_config('app.allow_feature_exception', 'false', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.tenant_feature_exceptions_log
|
||||
(tenant_id, feature_key, enabled, reason, created_by)
|
||||
VALUES
|
||||
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'tenant_id', p_tenant_id,
|
||||
'feature_key', v_feature_key,
|
||||
'enabled', p_enabled,
|
||||
'plan_allows', v_plan_allows,
|
||||
'is_exception', v_is_exception,
|
||||
'reason', v_reason
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) TO authenticated;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Policy: writes diretos só via saas_admin
|
||||
-- (tenant_admin agora muda só via RPC set_tenant_feature_exception)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
|
||||
DROP POLICY IF EXISTS tenant_features_write_saas_only ON public.tenant_features;
|
||||
|
||||
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000002_features_is_active
|
||||
-- V#40 — features hard-deleted: adiciona is_active para soft-delete.
|
||||
--
|
||||
-- Estratégia conservadora:
|
||||
-- - features.is_active boolean DEFAULT true NOT NULL
|
||||
-- - SaasFeaturesPage substitui DELETE por UPDATE is_active=false
|
||||
-- - Views que expõem features para o app (v_tenant_entitlements etc) NÃO são
|
||||
-- alteradas: features depreciadas ainda servem tenants legados via plan_features
|
||||
-- enquanto não houver migração explícita
|
||||
-- - Permite reativar feature acidentalmente deprecada
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.features
|
||||
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_features_is_active
|
||||
ON public.features (is_active) WHERE is_active = false;
|
||||
|
||||
COMMENT ON COLUMN public.features.is_active IS
|
||||
'V#40: false = feature depreciada, escondida no catálogo SaaS mas continua válida em planos/tenants existentes.';
|
||||
@@ -0,0 +1,69 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000003_delete_plan_safe
|
||||
-- V#36 — DELETE de plans sem checagem de assinaturas ativas pode quebrar tenants.
|
||||
--
|
||||
-- Cria RPC delete_plan_safe(plan_id) que:
|
||||
-- - Valida saas_admin
|
||||
-- - Conta subscriptions ativas (status='active') no plano
|
||||
-- - Se houver, RAISE EXCEPTION descritivo com a contagem
|
||||
-- - Se OK, desativa prices ativos e deleta o plano (atomic)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_plan_safe(
|
||||
p_plan_id uuid
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_active_count int;
|
||||
v_plan_key text;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF p_plan_id IS NULL THEN
|
||||
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
|
||||
IF v_plan_key IS NULL THEN
|
||||
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_active_count
|
||||
FROM public.subscriptions
|
||||
WHERE plan_id = p_plan_id
|
||||
AND status = 'active';
|
||||
|
||||
IF v_active_count > 0 THEN
|
||||
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
|
||||
v_plan_key, v_active_count
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- desativa preços ativos antes de deletar
|
||||
UPDATE public.plan_prices
|
||||
SET is_active = false,
|
||||
active_to = now()
|
||||
WHERE plan_id = p_plan_id
|
||||
AND is_active = true;
|
||||
|
||||
DELETE FROM public.plans WHERE id = p_plan_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'plan_key', v_plan_key
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.delete_plan_safe(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.delete_plan_safe(uuid) TO authenticated;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000004_consolidate_policies
|
||||
-- V#35 — Consolida policies duplicadas em plans, features, plan_features e
|
||||
-- subscriptions. Remove legado redundante e documenta as que ficam.
|
||||
--
|
||||
-- Análise (auditada via pg_policies):
|
||||
-- • plans/features/plan_features: cada uma tem "read * (auth)" duplicado
|
||||
-- com "*_read_authenticated" (mesmo USING true). Removidos os legados.
|
||||
-- • subscriptions:
|
||||
-- - "subscriptions read own" (USING user_id = auth.uid()) é SUBSET de
|
||||
-- "subscriptions_read_own" (USING user_id = auth.uid() OR is_saas_admin())
|
||||
-- - "subscriptions_select_own_personal" (user_id = auth.uid() AND tenant_id IS NULL)
|
||||
-- é SUBSET de "subscriptions_read_own"
|
||||
-- - "subscriptions_no_direct_update" (USING false) é no-op em OR com
|
||||
-- "subscriptions_update_only_saas_admin"
|
||||
-- Removidas as 3 redundâncias.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Drops dos legados redundantes
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "read plans (auth)" ON public.plans;
|
||||
DROP POLICY IF EXISTS "read features (auth)" ON public.features;
|
||||
DROP POLICY IF EXISTS "read plan_features (auth)" ON public.plan_features;
|
||||
|
||||
DROP POLICY IF EXISTS "subscriptions read own" ON public.subscriptions;
|
||||
DROP POLICY IF EXISTS "subscriptions_select_own_personal" ON public.subscriptions;
|
||||
DROP POLICY IF EXISTS "subscriptions_no_direct_update" ON public.subscriptions;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- COMMENT ON POLICY — documenta escopo das que ficaram
|
||||
-- -----------------------------------------------------------------------------
|
||||
COMMENT ON POLICY plans_read_authenticated ON public.plans IS 'Qualquer usuário autenticado lê o catálogo de planos (vitrine, upgrade UI).';
|
||||
COMMENT ON POLICY plans_write_saas_admin ON public.plans IS 'Somente saas_admin escreve. DELETE deve ser via RPC delete_plan_safe (V#36).';
|
||||
|
||||
COMMENT ON POLICY features_read_authenticated ON public.features IS 'Qualquer logado lê o catálogo de features.';
|
||||
COMMENT ON POLICY features_write_saas_admin ON public.features IS 'Somente saas_admin escreve. DELETE = soft delete via is_active=false (V#40).';
|
||||
|
||||
COMMENT ON POLICY plan_features_read_authenticated ON public.plan_features IS 'Qualquer logado lê o vínculo plano↔feature (necessário para entitlements).';
|
||||
COMMENT ON POLICY plan_features_write_saas_admin ON public.plan_features IS 'Somente saas_admin escreve.';
|
||||
|
||||
COMMENT ON POLICY subscriptions_read_own ON public.subscriptions IS 'Dono da assinatura (user_id) ou saas_admin. Cobre o caso pessoal.';
|
||||
COMMENT ON POLICY subscriptions_select_for_tenant_members ON public.subscriptions IS 'Membros ativos do tenant leem assinaturas do tenant.';
|
||||
COMMENT ON POLICY "subscriptions: read if linked owner_users" ON public.subscriptions IS 'Caso especial: usuários ligados ao owner via owner_users (terapeutas de uma clínica que precisam ver a assinatura do owner).';
|
||||
COMMENT ON POLICY subscriptions_insert_own_personal ON public.subscriptions IS 'Usuário cria a própria assinatura pessoal (intent → conversion).';
|
||||
COMMENT ON POLICY subscriptions_update_only_saas_admin ON public.subscriptions IS 'UPDATE direto somente via saas_admin. Mudanças de tenant devem passar por RPC dedicada.';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000005_restrict_intake_rpc
|
||||
-- A#20 — Restringe create_patient_intake_request_v2 a service_role.
|
||||
--
|
||||
-- Antes: anon (e PUBLIC) podia chamar direto. Bot bypassava qualquer
|
||||
-- proteção do front (Turnstile etc).
|
||||
-- Agora: edge function `submit-patient-intake` valida CAPTCHA e chama
|
||||
-- a RPC com service_role. Anon não chama mais a RPC direto.
|
||||
-- =============================================================================
|
||||
|
||||
-- Revoga PUBLIC (DEFAULT) e anon
|
||||
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) FROM PUBLIC, anon;
|
||||
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) FROM PUBLIC, anon;
|
||||
|
||||
-- Mantém grants explícitos pra authenticated (uso interno futuro) e service_role (edge function)
|
||||
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) TO authenticated, service_role;
|
||||
|
||||
-- Mesma proteção para RPC v1 legada (caso ainda exista)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public' AND p.proname = 'create_patient_intake_request'
|
||||
) THEN
|
||||
EXECUTE 'REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) FROM PUBLIC, anon';
|
||||
EXECUTE 'GRANT EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) TO authenticated, service_role';
|
||||
END IF;
|
||||
END$$;
|
||||
@@ -0,0 +1,136 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000006_layered_bot_defense
|
||||
-- A#20 (rev2) — Defesa em camadas self-hosted (substitui Turnstile).
|
||||
--
|
||||
-- Camadas:
|
||||
-- 1. Honeypot field (no front) → invisível, sempre ativo
|
||||
-- 2. Rate limit por IP no edge → submission_rate_limits
|
||||
-- 3. Math captcha CONDICIONAL → só se IP teve N falhas recentes
|
||||
-- 4. Logging em public_submission_attempts (genérico, não só intake)
|
||||
-- 5. Modo paranoid global → saas_security_config.captcha_required
|
||||
--
|
||||
-- Substitui chamadas Turnstile na edge function submit-patient-intake.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. saas_security_config (singleton)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.saas_security_config (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
honeypot_enabled boolean NOT NULL DEFAULT true,
|
||||
rate_limit_enabled boolean NOT NULL DEFAULT true,
|
||||
rate_limit_window_min integer NOT NULL DEFAULT 10,
|
||||
rate_limit_max_attempts integer NOT NULL DEFAULT 5,
|
||||
captcha_after_failures integer NOT NULL DEFAULT 3,
|
||||
captcha_required_globally boolean NOT NULL DEFAULT false,
|
||||
block_duration_min integer NOT NULL DEFAULT 30,
|
||||
captcha_required_window_min integer NOT NULL DEFAULT 60,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_security_config_singleton CHECK (id = true)
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_security_config (id) VALUES (true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.saas_security_config FROM anon, authenticated;
|
||||
GRANT SELECT, UPDATE ON public.saas_security_config TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS saas_security_config_read ON public.saas_security_config;
|
||||
CREATE POLICY saas_security_config_read ON public.saas_security_config
|
||||
FOR SELECT TO authenticated
|
||||
USING (true); -- qualquer logado pode ler config global (não tem segredo)
|
||||
|
||||
DROP POLICY IF EXISTS saas_security_config_write ON public.saas_security_config;
|
||||
CREATE POLICY saas_security_config_write ON public.saas_security_config
|
||||
FOR UPDATE TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.saas_security_config IS 'Singleton: configuração global de defesa contra bots em endpoints públicos.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. public_submission_attempts (log genérico)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.public_submission_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
endpoint text NOT NULL,
|
||||
ip_hash text,
|
||||
success boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
blocked_by text, -- 'honeypot' | 'rate_limit' | 'captcha' | 'rpc' | null
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_endpoint_created ON public.public_submission_attempts (endpoint, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_ip_hash_created ON public.public_submission_attempts (ip_hash, created_at DESC) WHERE ip_hash IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_failed ON public.public_submission_attempts (created_at DESC) WHERE success = false;
|
||||
|
||||
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.public_submission_attempts FROM anon, authenticated;
|
||||
GRANT SELECT ON public.public_submission_attempts TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS psa_read_saas_admin ON public.public_submission_attempts;
|
||||
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.public_submission_attempts IS 'Log de tentativas em endpoints públicos (intake, signup, agendador). Escrita apenas via RPC SECURITY DEFINER.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. submission_rate_limits (estado vigente por IP+endpoint)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.submission_rate_limits (
|
||||
ip_hash text NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
fail_count integer NOT NULL DEFAULT 0,
|
||||
window_start timestamptz NOT NULL DEFAULT now(),
|
||||
blocked_until timestamptz,
|
||||
requires_captcha_until timestamptz,
|
||||
last_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (ip_hash, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_srl_blocked_until ON public.submission_rate_limits (blocked_until) WHERE blocked_until IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_srl_endpoint ON public.submission_rate_limits (endpoint, last_attempt_at DESC);
|
||||
|
||||
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.submission_rate_limits FROM anon, authenticated;
|
||||
GRANT SELECT ON public.submission_rate_limits TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS srl_read_saas_admin ON public.submission_rate_limits;
|
||||
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.submission_rate_limits IS 'Estado de rate limit por IP+endpoint. Escrita apenas via RPC. SaaS admin lê pra dashboard.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. math_challenges (TTL 5min, limpa via cron)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.math_challenges (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
question text NOT NULL,
|
||||
answer integer NOT NULL,
|
||||
used boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL DEFAULT (now() + interval '5 minutes')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mc_expires ON public.math_challenges (expires_at);
|
||||
|
||||
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.math_challenges FROM anon, authenticated;
|
||||
-- nenhum grant: tabela acessada apenas via RPC SECURITY DEFINER
|
||||
|
||||
COMMENT ON TABLE public.math_challenges IS 'Challenges de math captcha. TTL 5min. Escrita/leitura apenas via RPC.';
|
||||
@@ -0,0 +1,299 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000007_bot_defense_rpcs
|
||||
-- A#20 (rev2) — RPCs da defesa em camadas:
|
||||
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
|
||||
-- • record_submission_attempt — log + atualiza contadores e bloqueios
|
||||
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
|
||||
-- • verify_math_challenge — valida {id, answer}, marca used
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- check_rate_limit
|
||||
-- Lê config + estado atual, decide o que retornar.
|
||||
-- Se fora da janela atual, "rolha" os contadores (reset).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.check_rate_limit(
|
||||
p_ip_hash text,
|
||||
p_endpoint text
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_security_config%ROWTYPE;
|
||||
rl submission_rate_limits%ROWTYPE;
|
||||
v_now timestamptz := now();
|
||||
v_window_start timestamptz;
|
||||
v_in_window boolean;
|
||||
v_requires_captcha boolean := false;
|
||||
v_blocked_until timestamptz;
|
||||
v_retry_after_seconds integer := 0;
|
||||
BEGIN
|
||||
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
|
||||
IF NOT FOUND THEN
|
||||
-- Sem config: fail-open (libera). Logado.
|
||||
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
|
||||
END IF;
|
||||
|
||||
-- Modo paranoid global: sempre captcha
|
||||
IF cfg.captcha_required_globally THEN
|
||||
v_requires_captcha := true;
|
||||
END IF;
|
||||
|
||||
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
|
||||
IF NOT cfg.rate_limit_enabled THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Sem ip_hash: libera (não dá pra rastrear)
|
||||
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', 'no_ip'
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO rl
|
||||
FROM submission_rate_limits
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
-- Bloqueio temporário ativo?
|
||||
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
|
||||
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'requires_captcha', false,
|
||||
'retry_after_seconds', v_retry_after_seconds,
|
||||
'reason', 'blocked'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Captcha condicional ativo?
|
||||
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
|
||||
v_requires_captcha := true;
|
||||
END IF;
|
||||
|
||||
-- Janela atual ainda válida?
|
||||
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
|
||||
v_in_window := FOUND AND rl.window_start >= v_window_start;
|
||||
|
||||
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
|
||||
-- Excedeu — bloqueia
|
||||
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
|
||||
UPDATE submission_rate_limits
|
||||
SET blocked_until = v_blocked_until,
|
||||
last_attempt_at = v_now
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'requires_captcha', false,
|
||||
'retry_after_seconds', v_retry_after_seconds,
|
||||
'reason', 'rate_limit_exceeded'
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- record_submission_attempt
|
||||
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
|
||||
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
|
||||
-- requires_captcha_until = now + captcha_required_window_min.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
|
||||
p_endpoint text,
|
||||
p_ip_hash text,
|
||||
p_success boolean,
|
||||
p_blocked_by text DEFAULT NULL,
|
||||
p_error_code text DEFAULT NULL,
|
||||
p_error_msg text DEFAULT NULL,
|
||||
p_user_agent text DEFAULT NULL,
|
||||
p_metadata jsonb DEFAULT NULL
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_security_config%ROWTYPE;
|
||||
v_now timestamptz := now();
|
||||
v_window_start timestamptz;
|
||||
rl submission_rate_limits%ROWTYPE;
|
||||
BEGIN
|
||||
-- Log sempre (mesmo sem ip)
|
||||
INSERT INTO public_submission_attempts
|
||||
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
|
||||
VALUES
|
||||
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
|
||||
left(coalesce(p_error_code, ''), 80),
|
||||
left(coalesce(p_error_msg, ''), 500),
|
||||
left(coalesce(p_user_agent, ''), 500),
|
||||
p_metadata);
|
||||
|
||||
-- Sem ip ou rate limit desligado: não atualiza contador
|
||||
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
|
||||
|
||||
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
|
||||
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
|
||||
|
||||
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
|
||||
|
||||
SELECT * INTO rl
|
||||
FROM submission_rate_limits
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
INSERT INTO submission_rate_limits
|
||||
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
|
||||
VALUES
|
||||
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
|
||||
ELSE
|
||||
IF rl.window_start < v_window_start THEN
|
||||
-- Reset janela
|
||||
UPDATE submission_rate_limits
|
||||
SET attempt_count = 1,
|
||||
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
|
||||
window_start = v_now,
|
||||
last_attempt_at = v_now,
|
||||
blocked_until = NULL
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
ELSE
|
||||
UPDATE submission_rate_limits
|
||||
SET attempt_count = attempt_count + 1,
|
||||
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
|
||||
last_attempt_at = v_now
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
END IF;
|
||||
|
||||
-- Se atingiu threshold de captcha condicional, marca
|
||||
IF NOT p_success THEN
|
||||
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
IF rl.fail_count >= cfg.captcha_after_failures
|
||||
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
|
||||
UPDATE submission_rate_limits
|
||||
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- generate_math_challenge
|
||||
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
|
||||
-- Operações: + - * (resultado sempre positivo)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_a integer;
|
||||
v_b integer;
|
||||
v_op text;
|
||||
v_ans integer;
|
||||
v_q text;
|
||||
v_id uuid;
|
||||
BEGIN
|
||||
v_a := 1 + floor(random() * 9)::int;
|
||||
v_b := 1 + floor(random() * 9)::int;
|
||||
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
|
||||
|
||||
-- garantir resultado positivo na subtração
|
||||
IF v_op = '-' AND v_b > v_a THEN
|
||||
v_a := v_a + v_b;
|
||||
END IF;
|
||||
|
||||
v_ans := CASE v_op
|
||||
WHEN '+' THEN v_a + v_b
|
||||
WHEN '-' THEN v_a - v_b
|
||||
WHEN '*' THEN v_a * v_b
|
||||
END;
|
||||
|
||||
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
|
||||
|
||||
INSERT INTO math_challenges (question, answer)
|
||||
VALUES (v_q, v_ans)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object('id', v_id, 'question', v_q);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- verify_math_challenge
|
||||
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
|
||||
p_id uuid,
|
||||
p_answer integer
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
mc math_challenges%ROWTYPE;
|
||||
BEGIN
|
||||
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
|
||||
|
||||
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
|
||||
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
UPDATE math_challenges SET used = true WHERE id = p_id;
|
||||
|
||||
RETURN mc.answer = p_answer;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- cleanup_expired_math_challenges (chamável via cron)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
|
||||
RETURNS integer
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH d AS (
|
||||
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*)::int FROM d;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
|
||||
@@ -0,0 +1,155 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000008_saas_twilio_config
|
||||
-- Permite saas_admin editar config Twilio operacional pelo painel, sem redeploy.
|
||||
--
|
||||
-- DECISÃO DE SEGURANÇA:
|
||||
-- • TWILIO_AUTH_TOKEN (secret) NÃO entra na tabela. Continua em env var
|
||||
-- da Edge Function. Painel apenas exibe se está configurado (best-effort).
|
||||
-- • TWILIO_ACCOUNT_SID (público no Twilio dashboard, identificador) → DB
|
||||
-- • TWILIO_WHATSAPP_WEBHOOK (URL) → DB
|
||||
-- • USD_BRL_RATE / MARGIN_MULTIPLIER (operacional) → DB
|
||||
--
|
||||
-- Edge function: lê primeiro do banco; cai pra env vars como fallback se row
|
||||
-- ainda não foi configurada (back-compat com deploys antigos).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.saas_twilio_config (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
account_sid text,
|
||||
whatsapp_webhook_url text,
|
||||
usd_brl_rate numeric(10,4) NOT NULL DEFAULT 5.5,
|
||||
margin_multiplier numeric(10,4) NOT NULL DEFAULT 1.4,
|
||||
notes text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_twilio_config_singleton CHECK (id = true),
|
||||
CONSTRAINT saas_twilio_config_rate_chk CHECK (usd_brl_rate > 0 AND usd_brl_rate < 100),
|
||||
CONSTRAINT saas_twilio_config_mult_chk CHECK (margin_multiplier >= 1 AND margin_multiplier <= 10),
|
||||
CONSTRAINT saas_twilio_config_sid_chk CHECK (account_sid IS NULL OR account_sid ~ '^AC[a-zA-Z0-9]{32}$'),
|
||||
CONSTRAINT saas_twilio_config_url_chk CHECK (whatsapp_webhook_url IS NULL OR whatsapp_webhook_url ~ '^https?://')
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_twilio_config (id) VALUES (true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.saas_twilio_config FROM anon, authenticated;
|
||||
GRANT SELECT ON public.saas_twilio_config TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS saas_twilio_config_read ON public.saas_twilio_config;
|
||||
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin()); -- só admin vê config (mesmo sem secret, é dado operacional)
|
||||
|
||||
COMMENT ON TABLE public.saas_twilio_config IS
|
||||
'Config operacional Twilio editável via painel. AUTH_TOKEN continua em env var por segurança.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- RPC get_twilio_config — retorna config atual (saas_admin OU service_role)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_twilio_config()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_twilio_config%ROWTYPE;
|
||||
BEGIN
|
||||
-- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
|
||||
-- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
|
||||
IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
|
||||
RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object(
|
||||
'account_sid', NULL,
|
||||
'whatsapp_webhook_url', NULL,
|
||||
'usd_brl_rate', 5.5,
|
||||
'margin_multiplier', 1.4
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'account_sid', cfg.account_sid,
|
||||
'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
|
||||
'usd_brl_rate', cfg.usd_brl_rate,
|
||||
'margin_multiplier', cfg.margin_multiplier,
|
||||
'notes', cfg.notes,
|
||||
'updated_at', cfg.updated_at,
|
||||
'updated_by', cfg.updated_by
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_twilio_config() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.get_twilio_config() TO authenticated, service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- RPC update_twilio_config — só saas_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.update_twilio_config(
|
||||
p_account_sid text DEFAULT NULL,
|
||||
p_whatsapp_webhook_url text DEFAULT NULL,
|
||||
p_usd_brl_rate numeric DEFAULT NULL,
|
||||
p_margin_multiplier numeric DEFAULT NULL,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller uuid := auth.uid();
|
||||
v_account_sid text;
|
||||
v_webhook_url text;
|
||||
v_notes text;
|
||||
BEGIN
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Sanitização
|
||||
v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
|
||||
v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
|
||||
v_notes := nullif(btrim(coalesce(p_notes, '')), '');
|
||||
|
||||
IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
|
||||
RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
|
||||
RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
|
||||
RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
|
||||
RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
|
||||
v_notes := substring(v_notes FROM 1 FOR 1000);
|
||||
END IF;
|
||||
|
||||
UPDATE saas_twilio_config
|
||||
SET account_sid = COALESCE(v_account_sid, account_sid),
|
||||
whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
|
||||
usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate),
|
||||
margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier),
|
||||
notes = COALESCE(v_notes, notes),
|
||||
updated_at = now(),
|
||||
updated_by = v_caller
|
||||
WHERE id = true;
|
||||
|
||||
RETURN public.get_twilio_config();
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) TO authenticated;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000009_patient_session_counts_rpc
|
||||
-- V#8 — Substitui o .limit(1000) arbitrário em PatientsListPage por RPC
|
||||
-- agregada que retorna contagens por paciente (sempre atualizada, sem teto).
|
||||
--
|
||||
-- Tenant scoping é feito via WHERE tenant_id IN (memberships do caller),
|
||||
-- consistente com a policy SELECT de agenda_eventos.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_patient_session_counts(
|
||||
p_patient_ids uuid[]
|
||||
)
|
||||
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT
|
||||
ae.patient_id,
|
||||
COUNT(*)::int AS session_count,
|
||||
MAX(ae.inicio_em) AS last_session_at
|
||||
FROM public.agenda_eventos ae
|
||||
WHERE ae.patient_id = ANY(p_patient_ids)
|
||||
AND ae.tenant_id IN (
|
||||
SELECT tm.tenant_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
GROUP BY ae.patient_id;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_patient_session_counts(uuid[]) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_patient_session_counts(uuid[]) TO authenticated;
|
||||
@@ -0,0 +1,304 @@
|
||||
-- =============================================================================
|
||||
-- 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());
|
||||
Reference in New Issue
Block a user