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>
137 lines
6.5 KiB
PL/PgSQL
137 lines
6.5 KiB
PL/PgSQL
-- ============================================================
|
|
-- 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;
|