125 lines
4.5 KiB
PL/PgSQL
125 lines
4.5 KiB
PL/PgSQL
-- ============================================================
|
|
-- 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;
|