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