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:
@@ -1,7 +1,10 @@
|
||||
'use server'
|
||||
import { headers } from 'next/headers'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { createHash } from 'crypto'
|
||||
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
|
||||
import { buildFormSchema } from '@/lib/form-engine'
|
||||
import type { Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||
import { validateSubmission, type SubmitAnswerEntry } from '@/lib/submit-validation'
|
||||
|
||||
export type SubmitResult =
|
||||
| { success: true }
|
||||
@@ -9,103 +12,83 @@ export type SubmitResult =
|
||||
|
||||
export async function submitSurvey(payload: {
|
||||
surveyId: string
|
||||
answers: Record<string, unknown>
|
||||
questionMap: Record<string, string> // código → question_id UUID
|
||||
answers: SubmitAnswerEntry[]
|
||||
consentOperativo: boolean
|
||||
consentMacroN1: boolean
|
||||
}): Promise<SubmitResult> {
|
||||
// 1. ip_hash: server-only, nunca en texto plano
|
||||
// ip_hash: server-only, nunca en texto plano (dedup sin identidad)
|
||||
const headersList = await headers()
|
||||
const rawIp =
|
||||
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const salt = process.env.IP_HASH_SALT ?? ''
|
||||
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
||||
|
||||
// 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon)
|
||||
// NUNCA usar service_role — el anon key respeta RLS
|
||||
// Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey)
|
||||
|
||||
// 3. Leer survey para obtener org_id y consent_version_id
|
||||
// 1. Validación #1: survey existe y está activa
|
||||
const { data: survey, error: sErr } = await supabase
|
||||
.from('surveys')
|
||||
.select('org_id, consent_version_id')
|
||||
.select('id, org_id, status')
|
||||
.eq('id', payload.surveyId)
|
||||
.single()
|
||||
.eq('status', 'live')
|
||||
.maybeSingle()
|
||||
|
||||
if (sErr || !survey) return { success: false, error: 'survey_not_found' }
|
||||
if (sErr) return { success: false, error: 'survey_lookup_failed' }
|
||||
if (!survey) return { success: false, error: 'survey_not_found_or_not_live' }
|
||||
|
||||
// 4. Pre-generar UUIDs server-side para evitar .select().single() post-INSERT.
|
||||
//
|
||||
// DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que
|
||||
// requiere SELECT policy para anon en consent_records/responses. Esa policy no existe
|
||||
// (correcto — anon no debe leer consent_records de otros respondentes).
|
||||
// Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT.
|
||||
const consentId = randomUUID()
|
||||
const responseId = randomUUID()
|
||||
|
||||
// 5. INSERT consent_record (inmutable, ADR-003)
|
||||
// Sin .select() para no activar RETURNING y evitar el error RLS 42501.
|
||||
const { error: cErr } = await supabase
|
||||
.from('consent_records')
|
||||
.insert({
|
||||
id: consentId,
|
||||
consent_version_id: survey.consent_version_id,
|
||||
granted_scopes: {
|
||||
operativo: payload.consentOperativo,
|
||||
macro_n1: payload.consentMacroN1,
|
||||
},
|
||||
granted_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (cErr) return { success: false, error: `consent_insert_failed: ${cErr.message}` }
|
||||
|
||||
// 6. Extraer qi_* de las respuestas
|
||||
const qi_age_bucket =
|
||||
typeof payload.answers['A5'] === 'string' ? payload.answers['A5'] : null
|
||||
const qi_sector =
|
||||
typeof payload.answers['A4'] === 'string' ? payload.answers['A4'] : null
|
||||
const qi_zone =
|
||||
typeof payload.answers['A1'] === 'string' ? payload.answers['A1'] : null
|
||||
|
||||
// 7. INSERT response (respondent_id=null — encuesta anónima, regla 2 CLAUDE.md)
|
||||
const { error: rErr } = await supabase
|
||||
.from('responses')
|
||||
.insert({
|
||||
id: responseId,
|
||||
survey_id: payload.surveyId,
|
||||
company_id: survey.org_id,
|
||||
respondent_id: null,
|
||||
ip_hash: ipHash,
|
||||
consent_record_id: consentId,
|
||||
qi_age_bucket: qi_age_bucket || null,
|
||||
qi_sector: qi_sector || null,
|
||||
qi_zone: qi_zone || null,
|
||||
})
|
||||
|
||||
if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` }
|
||||
|
||||
// 8. INSERT answers — una fila por pregunta respondida
|
||||
const answerRows = Object.entries(payload.answers)
|
||||
.map(([code, value]) => ({
|
||||
response_id: responseId,
|
||||
question_id: payload.questionMap[code],
|
||||
value,
|
||||
}))
|
||||
.filter(
|
||||
(row) =>
|
||||
row.question_id &&
|
||||
row.value !== null &&
|
||||
row.value !== undefined &&
|
||||
row.value !== '' &&
|
||||
!(Array.isArray(row.value) && row.value.length === 0)
|
||||
// 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
|
||||
// nunca contra lo que el cliente afirma que vio.
|
||||
const { data: questionRows, error: qErr } = await supabase
|
||||
.from('questions')
|
||||
.select(
|
||||
'id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||
)
|
||||
.eq('survey_id', payload.surveyId)
|
||||
|
||||
if (answerRows.length > 0) {
|
||||
const { error: aErr } = await supabase.from('answers').insert(answerRows)
|
||||
if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` }
|
||||
if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
|
||||
|
||||
const schema = buildFormSchema(questionRows as unknown as EngineQuestion[])
|
||||
|
||||
// 3. Validaciones #2-#7 — reusa lib/form-engine.ts, no reimplementa flujo.
|
||||
const result = validateSubmission(schema, payload.answers, payload.consentOperativo)
|
||||
if (!result.ok) {
|
||||
return { success: false, error: `validation_failed: ${result.errors.join(', ')}` }
|
||||
}
|
||||
|
||||
// 4. Cuasi-identificadores — códigos v3 (2.1 zona, 2.4 edad).
|
||||
// qi_sector: v3 no tiene una pregunta equivalente a "área/gerencia" de v2.
|
||||
// Se usa 2.6 (nivel organizacional) como proxy más cercano disponible —
|
||||
// decisión a confirmar con el director, no hay un mapeo exacto.
|
||||
const findDefault = (code: string): string | null => {
|
||||
const entry = payload.answers.find((e) => e.code === code && e.instanceId === 'default')
|
||||
return typeof entry?.value === 'string' ? entry.value : null
|
||||
}
|
||||
const qiZone = findDefault('2.1')
|
||||
const qiAgeBucket = findDefault('2.4')
|
||||
const qiSector = findDefault('2.6')
|
||||
|
||||
// 5. Persistencia atómica vía RPC SECURITY DEFINER — ver migración
|
||||
// 20260624000001_submit_rpc.sql para el detalle de por qué RPC y no
|
||||
// INSERTs sueltos desde acá.
|
||||
const { error: rpcErr } = await supabase.rpc('rpc_submit_response', {
|
||||
p_survey_id: payload.surveyId,
|
||||
p_ip_hash: ipHash,
|
||||
p_qi_age_bucket: qiAgeBucket,
|
||||
p_qi_sector: qiSector,
|
||||
p_qi_zone: qiZone,
|
||||
p_consent_operativo: payload.consentOperativo,
|
||||
p_consent_macro_n1: payload.consentMacroN1,
|
||||
p_answers: result.rows.map((r) => ({
|
||||
question_id: r.questionId,
|
||||
instance_id: r.instanceId,
|
||||
value: r.value,
|
||||
})),
|
||||
})
|
||||
|
||||
if (rpcErr) return { success: false, error: 'submit_failed' }
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user