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:
136
supabase/migrations/20260624000001_submit_rpc.sql
Normal file
136
supabase/migrations/20260624000001_submit_rpc.sql
Normal 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;
|
||||
Reference in New Issue
Block a user