feat(submit): submit v3 con validación de integridad server-side [4G]

RPC transaccional SECURITY DEFINER (rpc_submit_response) como único camino
de escritura. Dropea las policies WITH CHECK(true): cierra la falla donde
anon podía insertar cualquier cosa. Validación server-side (7 checks)
reusando el motor. Persistencia atómica con instance_id por instancia.
Verificado contra Postgres real: instancias separadas, atomicidad, RLS.
Riesgo residual de bypass por REST documentado (SEC-004).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-06-24 21:27:49 -03:00
parent 61676e1423
commit 2e6fbd7801
10 changed files with 701 additions and 91 deletions

View File

@@ -0,0 +1,136 @@
-- ============================================================
-- 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;