-- ============================================================ -- Migration 005: Función agg_segment — agregación con k-anonimato -- -- DISEÑO DE SEGURIDAD: -- SECURITY DEFINER → puede leer responses directamente (bypasa RLS). -- Pero aplica tres capas de protección propias: -- a) Valida que el caller (org_admin) sea dueño de la encuesta. -- b) Whitelists las dimensiones permitidas (previene SQL injection). -- c) Suprime toda celda con count < k_threshold → devuelve n=NULL, -- suppressed=TRUE en lugar del valor real. -- -- El texto libre (type='text_long') NUNCA aparece en el output: -- la función agrega solo sobre cuasi-identificadores de responses, -- no sobre el contenido de answers (regla no negociable #4 CLAUDE.md). -- -- SIGNATURE: -- agg_segment(p_survey_id uuid, p_dimensions text[]) -- RETURNS TABLE(segment jsonb, n bigint, suppressed boolean) -- -- p_dimensions: subconjunto de ['qi_age_bucket', 'qi_sector', 'qi_zone'] -- segment: objeto JSONB con los valores de las dimensiones pedidas -- n: count del segmento, o NULL si suppressed -- suppressed: TRUE si count < k_threshold de la encuesta -- ============================================================ CREATE OR REPLACE FUNCTION public.agg_segment( p_survey_id uuid, p_dimensions text[] ) RETURNS TABLE ( segment jsonb, n bigint, suppressed boolean ) LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_k_threshold int; v_valid_dims CONSTANT text[] := ARRAY['qi_age_bucket', 'qi_sector', 'qi_zone']; v_dim text; v_select_cols text[] := ARRAY[]::text[]; v_json_kv text[] := ARRAY[]::text[]; v_group_cols text; v_inner_sql text; v_outer_sql text; BEGIN -- 1. Verificar que la encuesta existe y obtener su umbral de k-anonimato SELECT k_threshold INTO v_k_threshold FROM public.surveys WHERE id = p_survey_id; IF NOT FOUND THEN RAISE EXCEPTION 'Survey not found: %', p_survey_id; END IF; -- 2. Verificar autorización del caller -- Si es org_admin, la encuesta debe pertenecer a su organización. -- analyst usa service_role (bypasa RLS y esta función), no llega acá con app_role='analyst'. IF public.current_app_role() = 'org_admin' THEN IF NOT EXISTS ( SELECT 1 FROM public.surveys WHERE id = p_survey_id AND org_id = public.current_org_id() ) THEN RAISE EXCEPTION 'Access denied: survey % does not belong to caller organization', p_survey_id; END IF; END IF; -- 3. Validar que se pidió al menos una dimensión IF p_dimensions IS NULL OR array_length(p_dimensions, 1) IS NULL THEN RAISE EXCEPTION 'At least one dimension is required'; END IF; -- 4. Validar dimensiones contra whitelist (prevención de SQL injection) FOREACH v_dim IN ARRAY p_dimensions LOOP IF NOT (v_dim = ANY(v_valid_dims)) THEN RAISE EXCEPTION 'Invalid dimension: "%" — allowed: %', v_dim, array_to_string(v_valid_dims, ', '); END IF; v_select_cols := v_select_cols || quote_ident(v_dim); v_json_kv := v_json_kv || (quote_literal(v_dim) || ', ' || quote_ident(v_dim)); END LOOP; v_group_cols := array_to_string(v_select_cols, ', '); -- 5. SQL interno: agrupar responses por las dimensiones pedidas v_inner_sql := format( 'SELECT jsonb_build_object(%s) AS segment, count(*) AS raw_n FROM public.responses WHERE survey_id = %L GROUP BY %s', array_to_string(v_json_kv, ', '), p_survey_id, v_group_cols ); -- 6. SQL externo: aplicar umbral de k-anonimato -- n = NULL y suppressed = TRUE cuando raw_n < k_threshold -- n = raw_n y suppressed = FALSE cuando raw_n >= k_threshold v_outer_sql := format( 'SELECT segment, CASE WHEN raw_n >= %s THEN raw_n ELSE NULL END AS n, (raw_n < %s) AS suppressed FROM (%s) sub ORDER BY suppressed DESC, segment', v_k_threshold, v_k_threshold, v_inner_sql ); RETURN QUERY EXECUTE v_outer_sql; END; $$; -- org_admin puede llamar a esta función (aplica su propia autorización interna) GRANT EXECUTE ON FUNCTION public.agg_segment(uuid, text[]) TO authenticated;