-- ============================================================ -- Sprint 4 (v3.0.0) — Etapa 4G: submit transaccional + cierre de WITH CHECK(true) -- -- PROBLEMA QUE CIERRA: answers_insert/responses_insert/consent_records_insert -- permitían INSERT directo a anon con casi ninguna restricción real -- (answers_insert: WITH CHECK(true) literal) — el cliente era la única -- fuente de verdad sobre qué se guardaba. Closes BRIEF decisión 15. -- -- DISEÑO — dos capas, cada una validando lo que le corresponde: -- 1) lib/form-engine.ts + lib/submit-validation.ts, en el Server Action -- (Next.js, TypeScript): valida COHERENCIA DE FLUJO — visibilidad, -- instancias legítimas, opciones válidas, requeridas. Esto NO puede -- vivir en SQL sin reimplementar el motor (prohibido explícitamente). -- 2) Esta RPC (Postgres, SECURITY DEFINER): valida ESTRUCTURA — el -- question_id pertenece a esta encuesta, la encuesta está viva, el -- consentimiento llegó — y persiste todo de forma ATÓMICA (una -- llamada a función = una transacción; cualquier RAISE EXCEPTION -- revierte TODO, sin huérfanos). -- -- POR QUÉ SECURITY DEFINER: se revoca el INSERT directo de anon sobre las -- 3 tablas (paso 1 abajo). Sin SECURITY DEFINER, esta función correría -- con los privilegios de quien la llama (anon) y esas mismas policies -- recién revocadas la bloquearían a ELLA también. SECURITY DEFINER la -- hace correr con los privilegios de quien la creó (el owner de la -- migración, no anon) — por eso puede insertar después de que anon ya -- no puede hacerlo directo. Mismo patrón que las RPCs de dashboard -- existentes (rpc_dashboard_kpis, etc.), aplicado por primera vez a -- escritura en vez de lectura. -- -- LOS GRANTS DE TABLA (migration 006: GRANT INSERT ON answers TO anon...) -- NO se tocan acá — siguen el "diseño de capas" que esa migración ya -- documenta: Capa 1 (GRANT) da la capacidad de intentar el INSERT, -- Capa 2 (RLS, esta migración) lo deniega por default al no quedar -- ninguna policy permisiva. anon puede seguir "intentando" un INSERT -- directo — RLS lo rechaza igual, con RLS habilitado (migration 002) y -- cero policies de INSERT restantes en estas 3 tablas. -- -- RIESGO RESIDUAL DOCUMENTADO (no resuelto en esta etapa, ver reporte): -- cualquiera que llame a esta RPC directamente vía la REST API de -- Supabase (no a través de nuestro Server Action) puede mandar un -- payload que pase las validaciones ESTRUCTURALES de acá pero sea -- incoherente de flujo — porque el motor de flujo es TypeScript y no -- corre dentro de Postgres. Es un problema de calidad de datos de -- investigación, no de privacidad/k-anonimato (el dato sigue siendo -- anónimo, is_sensitive, sujeto al umbral k en agg_segment). Cerrarlo -- del todo excede el scope de 4G — candidato a BACKLOG.md si se decide -- perseguirlo (ej. firma del payload por el Server Action). -- ============================================================ -- ── 1. Revoca el INSERT directo — el único camino pasa a ser la RPC ─────── DROP POLICY IF EXISTS "consent_records_insert" ON public.consent_records; DROP POLICY IF EXISTS "responses_insert" ON public.responses; DROP POLICY IF EXISTS "answers_insert" ON public.answers; -- ── 2. RPC de submit ──────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.rpc_submit_response( p_survey_id uuid, p_ip_hash text, p_qi_age_bucket text, p_qi_sector text, p_qi_zone text, p_consent_operativo boolean, p_consent_macro_n1 boolean, p_answers jsonb -- [{"question_id":"...","instance_id":"familiar_1","value":...}, ...] ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_org_id uuid; v_cv_id uuid; v_consent_id uuid := gen_random_uuid(); v_response_id uuid := gen_random_uuid(); v_bad_refs int; BEGIN -- Defensa en profundidad: el Server Action ya confirmó esto antes de -- llamar, pero esta función no debe confiar en el caller. SELECT org_id, consent_version_id INTO v_org_id, v_cv_id FROM public.surveys WHERE id = p_survey_id AND status = 'live'; IF v_org_id IS NULL THEN RAISE EXCEPTION 'survey_not_found_or_not_live' USING ERRCODE = 'check_violation'; END IF; IF NOT p_consent_operativo THEN RAISE EXCEPTION 'consent_operativo_required' USING ERRCODE = 'check_violation'; END IF; -- Chequeo ESTRUCTURAL (no de flujo): cada question_id recibido existe -- y pertenece a ESTA encuesta; cada instance_id viene no-vacío. -- Visibilidad/instancia-legítima/opciones-válidas ya se validó en TS. SELECT count(*) INTO v_bad_refs FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb) LEFT JOIN public.questions q ON q.id = r.question_id AND q.survey_id = p_survey_id WHERE q.id IS NULL OR r.instance_id IS NULL OR r.instance_id = ''; IF v_bad_refs > 0 THEN RAISE EXCEPTION 'invalid_answer_reference' USING ERRCODE = 'check_violation'; END IF; -- consent_record (inmutable, ADR-003) INSERT INTO public.consent_records (id, consent_version_id, granted_scopes, granted_at) VALUES ( v_consent_id, v_cv_id, jsonb_build_object('operativo', p_consent_operativo, 'macro_n1', p_consent_macro_n1), now() ); -- response (respondent_id SIEMPRE NULL — encuesta anónima, regla #2 CLAUDE.md) INSERT INTO public.responses ( id, survey_id, company_id, respondent_id, ip_hash, consent_record_id, qi_age_bucket, qi_sector, qi_zone ) VALUES ( v_response_id, p_survey_id, v_org_id, NULL, p_ip_hash, v_consent_id, p_qi_age_bucket, p_qi_sector, p_qi_zone ); -- answers — un row por entrada validada, con su instance_id real INSERT INTO public.answers (response_id, question_id, instance_id, value) SELECT v_response_id, r.question_id, r.instance_id, r.value FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb); RETURN jsonb_build_object('response_id', v_response_id); END; $$; REVOKE ALL ON FUNCTION public.rpc_submit_response( uuid, text, text, text, text, boolean, boolean, jsonb ) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.rpc_submit_response( uuid, text, text, text, text, boolean, boolean, jsonb ) TO anon, authenticated;