Files
agenciapsilmno/database-novo/migrations/20260419000008_saas_twilio_config.sql
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00

156 lines
7.2 KiB
PL/PgSQL

-- =============================================================================
-- 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;