Primer commit - Sprint 1
This commit is contained in:
135
supabase/migrations/20260531000001_create_tables.sql
Normal file
135
supabase/migrations/20260531000001_create_tables.sql
Normal file
@@ -0,0 +1,135 @@
|
||||
-- ============================================================
|
||||
-- Migration 001: Create base tables
|
||||
-- Contract: DATA_MODEL.md — do not add tables without approval (Nivel 3)
|
||||
-- Dependency order: organizations, consent_versions → surveys → questions
|
||||
-- → consent_records → responses → answers
|
||||
-- ============================================================
|
||||
|
||||
-- organizations: empresas cliente
|
||||
CREATE TABLE IF NOT EXISTS public.organizations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
sector text,
|
||||
size_bucket text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- consent_versions: textos de consentimiento versionados (INMUTABLES — ver migration 004)
|
||||
CREATE TABLE IF NOT EXISTS public.consent_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
version text NOT NULL UNIQUE,
|
||||
body text NOT NULL,
|
||||
scopes jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- surveys: definición de cada estudio
|
||||
-- Dos ejes de clasificación ortogonales (ADR-001 §2):
|
||||
-- data_sensitivity → consentimiento / seguridad legal
|
||||
-- security_class → RLS / cifrado (ORTOGONAL al anterior)
|
||||
CREATE TABLE IF NOT EXISTS public.surveys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id uuid NOT NULL REFERENCES public.organizations(id),
|
||||
title text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'live', 'closed')),
|
||||
-- Eje 1: anonimato — irreversible una vez publicada
|
||||
is_anonymous boolean NOT NULL,
|
||||
-- Eje 2a: sensibilidad legal → alimenta consentimiento
|
||||
data_sensitivity text NOT NULL
|
||||
CHECK (data_sensitivity IN ('comun', 'sensible', 'anonimizado')),
|
||||
-- Eje 2b: seguridad → alimenta RLS/cifrado (ORTOGONAL al anterior)
|
||||
security_class text NOT NULL
|
||||
CHECK (security_class IN ('publico', 'interno', 'confidencial')),
|
||||
-- Rol de responsable (Modo A = nosotros; Modo B = empresa. v1 solo usa A)
|
||||
controller_role text NOT NULL DEFAULT 'A'
|
||||
CHECK (controller_role IN ('A', 'B')),
|
||||
consent_version_id uuid REFERENCES public.consent_versions(id),
|
||||
brand_config jsonb,
|
||||
-- Umbral de k-anonimato mandatorio (regla no negociable #3 CLAUDE.md)
|
||||
k_threshold int NOT NULL DEFAULT 5 CHECK (k_threshold > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
published_at timestamptz
|
||||
);
|
||||
|
||||
-- questions: preguntas de la encuesta
|
||||
-- is_sensitive: auto-true para tipos salud/discapacidad (ver trigger en migration 004)
|
||||
CREATE TABLE IF NOT EXISTS public.questions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id uuid NOT NULL REFERENCES public.surveys(id) ON DELETE CASCADE,
|
||||
code text NOT NULL,
|
||||
type text NOT NULL
|
||||
CHECK (type IN (
|
||||
'text_short', 'text_long',
|
||||
'single_choice', 'multiple_choice',
|
||||
'rating', 'nps', 'matrix', 'ranking',
|
||||
'date', 'slider', 'yes_no', 'section'
|
||||
)),
|
||||
prompt text NOT NULL,
|
||||
position int NOT NULL,
|
||||
is_sensitive boolean NOT NULL DEFAULT false,
|
||||
max_select int,
|
||||
options jsonb,
|
||||
-- Formato: [{ "if_question": "CODE", "op": "eq", "value": "yes", "action": "show|hide|skip", "target": "CODE" }]
|
||||
conditional_rules jsonb,
|
||||
required boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (survey_id, code),
|
||||
UNIQUE (survey_id, position)
|
||||
);
|
||||
|
||||
-- consent_records: qué consintió cada respuesta (INMUTABLE — ver trigger en migration 004)
|
||||
-- Sin UPDATE/DELETE — se inserta una vez junto con cada response
|
||||
CREATE TABLE IF NOT EXISTS public.consent_records (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
consent_version_id uuid NOT NULL REFERENCES public.consent_versions(id),
|
||||
granted_scopes jsonb NOT NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- responses: una submission
|
||||
-- respondent_id RESERVADO y NULL en encuestas anónimas (regla no negociable #2 CLAUDE.md)
|
||||
-- Trigger en migration 004 rechaza respondent_id no nulo cuando is_anonymous=true
|
||||
-- Cuasi-identificadores en bucket — minimización: rango etario, no fecha exacta
|
||||
CREATE TABLE IF NOT EXISTS public.responses (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id uuid NOT NULL REFERENCES public.surveys(id),
|
||||
company_id uuid NOT NULL REFERENCES public.organizations(id),
|
||||
respondent_id uuid, -- RESERVADO, NULL en v1 (encuestas anónimas)
|
||||
ip_hash text, -- SHA-256 + salt, dedup sin identidad
|
||||
consent_record_id uuid NOT NULL REFERENCES public.consent_records(id),
|
||||
submitted_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Cuasi-identificadores denormalizados para segmentación eficiente
|
||||
qi_age_bucket text,
|
||||
qi_sector text,
|
||||
qi_zone text
|
||||
);
|
||||
|
||||
-- answers: valor de cada pregunta en una submission
|
||||
-- El texto libre (text_long) NO se expone en agg_segment (regla no negociable #4 CLAUDE.md)
|
||||
CREATE TABLE IF NOT EXISTS public.answers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
response_id uuid NOT NULL REFERENCES public.responses(id) ON DELETE CASCADE,
|
||||
question_id uuid NOT NULL REFERENCES public.questions(id),
|
||||
value jsonb NOT NULL,
|
||||
UNIQUE (response_id, question_id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- Indexes
|
||||
-- ============================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_surveys_org_id ON public.surveys(org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_surveys_status ON public.surveys(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_survey ON public.questions(survey_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_position ON public.questions(survey_id, position);
|
||||
|
||||
-- Índice compuesto para agg_segment: filtro por encuesta y agrupación por QIs
|
||||
CREATE INDEX IF NOT EXISTS idx_responses_survey ON public.responses(survey_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_responses_company ON public.responses(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_responses_ip_hash ON public.responses(ip_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_responses_qi_seg ON public.responses(survey_id, qi_age_bucket, qi_sector, qi_zone);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_answers_response ON public.answers(response_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_answers_question ON public.answers(question_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consent_rec_cv ON public.consent_records(consent_version_id);
|
||||
13
supabase/migrations/20260531000002_enable_rls.sql
Normal file
13
supabase/migrations/20260531000002_enable_rls.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- ============================================================
|
||||
-- Migration 002: Enable Row Level Security en todas las tablas
|
||||
-- Intención de RLS: DATA_MODEL.md §"Intención de RLS"
|
||||
-- Policies concretas: migration 003
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.consent_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.surveys ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.consent_records ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.responses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.answers ENABLE ROW LEVEL SECURITY;
|
||||
148
supabase/migrations/20260531000003_rls_policies.sql
Normal file
148
supabase/migrations/20260531000003_rls_policies.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
-- ============================================================
|
||||
-- Migration 003: RLS helper functions + policies
|
||||
--
|
||||
-- Roles de la plataforma (via JWT custom claim "app_role"):
|
||||
-- 'org_admin' → empresa cliente: accede solo a agregados de su org, nunca filas crudas
|
||||
-- 'analyst' → análisis interno (InQ): accede vía service_role que bypasa RLS
|
||||
-- 'respondent' → rellenador de encuesta: puede insertar, no leer
|
||||
-- (no claim) → anon: puede leer encuestas live y consentimientos para rellenar el form
|
||||
--
|
||||
-- CRÍTICO: ninguna policy devuelve filas crudas de responses/answers a org_admin
|
||||
-- El acceso de org_admin es SOLO vía la función agg_segment (migration 005)
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- Funciones helper para leer JWT claims
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.current_app_role()
|
||||
RETURNS text
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT COALESCE(auth.jwt() ->> 'app_role', '')
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.current_org_id()
|
||||
RETURNS uuid
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT NULLIF(auth.jwt() ->> 'org_id', '')::uuid
|
||||
$$;
|
||||
|
||||
-- ============================================================
|
||||
-- organizations
|
||||
-- org_admin ve solo su propia org; analyst usa service_role
|
||||
-- ============================================================
|
||||
CREATE POLICY "org_select_own"
|
||||
ON public.organizations FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
(public.current_app_role() = 'org_admin' AND id = public.current_org_id())
|
||||
OR public.current_app_role() = 'analyst'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- consent_versions
|
||||
-- Cualquiera (incluido anon) puede leer el texto de consentimiento
|
||||
-- para mostrarlo al respondente antes de que envíe.
|
||||
-- INSERT: solo via service_role (sin policy de INSERT para auth/anon)
|
||||
-- UPDATE/DELETE: bloqueados por trigger en migration 004
|
||||
-- ============================================================
|
||||
CREATE POLICY "consent_versions_select_all"
|
||||
ON public.consent_versions FOR SELECT
|
||||
USING (true);
|
||||
|
||||
-- ============================================================
|
||||
-- surveys
|
||||
-- anon: solo encuestas live (para rellenar el formulario)
|
||||
-- org_admin: todas sus encuestas en cualquier estado
|
||||
-- analyst: service_role (bypasa RLS)
|
||||
-- ============================================================
|
||||
CREATE POLICY "surveys_select_live_anon"
|
||||
ON public.surveys FOR SELECT
|
||||
TO anon
|
||||
USING (status = 'live');
|
||||
|
||||
CREATE POLICY "surveys_select_auth"
|
||||
ON public.surveys FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.current_app_role() = 'analyst'
|
||||
OR (
|
||||
public.current_app_role() = 'org_admin'
|
||||
AND org_id = public.current_org_id()
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- questions
|
||||
-- anon: preguntas de encuestas live (necesario para mostrar el form)
|
||||
-- org_admin: preguntas de sus encuestas
|
||||
-- ============================================================
|
||||
CREATE POLICY "questions_select_live"
|
||||
ON public.questions FOR SELECT
|
||||
TO anon
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.surveys s
|
||||
WHERE s.id = survey_id AND s.status = 'live'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "questions_select_auth"
|
||||
ON public.questions FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.current_app_role() = 'analyst'
|
||||
OR (
|
||||
public.current_app_role() = 'org_admin'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.surveys s
|
||||
WHERE s.id = survey_id AND s.org_id = public.current_org_id()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- consent_records
|
||||
-- anon/authenticated pueden INSERT al enviar el formulario
|
||||
-- SELECT: solo service_role (analyst interno)
|
||||
-- UPDATE/DELETE: bloqueados por trigger en migration 004
|
||||
-- ============================================================
|
||||
CREATE POLICY "consent_records_insert"
|
||||
ON public.consent_records FOR INSERT
|
||||
TO anon, authenticated
|
||||
WITH CHECK (true);
|
||||
|
||||
-- ============================================================
|
||||
-- responses
|
||||
-- CRÍTICO: NO hay policy SELECT para org_admin ni anon.
|
||||
-- Resultado: org_admin recibe 0 filas en SELECT directo → solo puede
|
||||
-- acceder mediante agg_segment, que aplica k_threshold.
|
||||
-- INSERT: permitido a anon/authenticated solo para encuestas live.
|
||||
-- ============================================================
|
||||
CREATE POLICY "responses_insert"
|
||||
ON public.responses FOR INSERT
|
||||
TO anon, authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.surveys s
|
||||
WHERE s.id = survey_id AND s.status = 'live'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- answers
|
||||
-- Mismo patrón que responses: INSERT permitido, 0 SELECT para org_admin.
|
||||
-- El texto libre (type='text_long') no se expone en agg_segment (regla #4).
|
||||
-- ============================================================
|
||||
CREATE POLICY "answers_insert"
|
||||
ON public.answers FOR INSERT
|
||||
TO anon, authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.responses r
|
||||
WHERE r.id = response_id
|
||||
)
|
||||
);
|
||||
87
supabase/migrations/20260531000004_constraints_triggers.sql
Normal file
87
supabase/migrations/20260531000004_constraints_triggers.sql
Normal file
@@ -0,0 +1,87 @@
|
||||
-- ============================================================
|
||||
-- Migration 004: Constraints y triggers de privacidad
|
||||
--
|
||||
-- 1. Trigger: rechazar respondent_id no nulo en encuestas anónimas
|
||||
-- → Regla no negociable #2 de CLAUDE.md (barrera técnica)
|
||||
--
|
||||
-- 2. Trigger: inmutabilidad de consent_records
|
||||
-- → ADR-003: sin UPDATE/DELETE, evidencia permanente del consentimiento
|
||||
--
|
||||
-- 3. Trigger: inmutabilidad de consent_versions
|
||||
-- → Textos de consentimiento históricos son inmutables
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Trigger: respondent_id prohibido en encuestas anónimas
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.enforce_anonymous_no_respondent_id()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_is_anonymous boolean;
|
||||
BEGIN
|
||||
-- Leer la propiedad de la encuesta referida
|
||||
SELECT is_anonymous INTO v_is_anonymous
|
||||
FROM public.surveys
|
||||
WHERE id = NEW.survey_id;
|
||||
|
||||
IF v_is_anonymous AND NEW.respondent_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'Privacy violation: respondent_id must be NULL in anonymous surveys (survey_id: %). '
|
||||
'See CLAUDE.md rule #2.',
|
||||
NEW.survey_id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_responses_anonymous_no_respondent_id
|
||||
BEFORE INSERT OR UPDATE ON public.responses
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_anonymous_no_respondent_id();
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Trigger: inmutabilidad de consent_records (ADR-003)
|
||||
-- El consentimiento es evidencia, no UI. Nunca se modifica.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.deny_consent_records_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'consent_records is immutable: % is not permitted. '
|
||||
'Each response consent is permanent evidence (ADR-003).',
|
||||
TG_OP
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_consent_records_immutable
|
||||
BEFORE UPDATE OR DELETE ON public.consent_records
|
||||
FOR EACH ROW EXECUTE FUNCTION public.deny_consent_records_mutation();
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Trigger: inmutabilidad de consent_versions
|
||||
-- Los textos históricos no se alteran. Cambiar el wording del
|
||||
-- consentimiento crea una nueva versión, nunca edita la existente.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.deny_consent_versions_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'consent_versions is immutable: % is not permitted. '
|
||||
'Create a new version instead of modifying an existing one.',
|
||||
TG_OP
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_consent_versions_immutable
|
||||
BEFORE UPDATE OR DELETE ON public.consent_versions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.deny_consent_versions_mutation();
|
||||
124
supabase/migrations/20260531000005_agg_segment.sql
Normal file
124
supabase/migrations/20260531000005_agg_segment.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
78
supabase/migrations/20260531000006_grants.sql
Normal file
78
supabase/migrations/20260531000006_grants.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- ============================================================
|
||||
-- Migration 006: GRANTs explícitos de tabla
|
||||
--
|
||||
-- CONTEXTO: A partir de 2026-05-30, Supabase cambia el default de
|
||||
-- auto_expose_new_tables a false. Sin GRANTs explícitos, los roles
|
||||
-- anon/authenticated no pueden acceder a las tablas aunque haya
|
||||
-- políticas RLS. Las RLS policies son la capa de restricción por-fila;
|
||||
-- los GRANTs son la capa de acceso al objeto. Ambas son necesarias.
|
||||
--
|
||||
-- Diseño de capas para responses/answers:
|
||||
-- Capa 1 (objeto): authenticated tiene SELECT → el role puede hacer queries
|
||||
-- Capa 2 (RLS): sin policy SELECT para org_admin → resultado = 0 filas
|
||||
-- Capa 3 (datos): para obtener datos reales, org_admin llama agg_segment,
|
||||
-- que aplica k_threshold internamente (SECURITY DEFINER)
|
||||
--
|
||||
-- Esta layering cumple la intención del DATA_MODEL: "0 filas crudas",
|
||||
-- no "permission denied". El resultado es indistinguible para el caller
|
||||
-- (tabla vacía), pero la arquitectura es auditable y testeable con RLS.
|
||||
--
|
||||
-- Principio mínimo de privilegios:
|
||||
-- anon → solo lo necesario para responder una encuesta
|
||||
-- authenticated → lo que necesita org_admin + respondentes autenticados
|
||||
-- service_role → bypasa RLS, no necesita GRANTs adicionales aquí
|
||||
-- ============================================================
|
||||
|
||||
-- Acceso al schema public
|
||||
GRANT USAGE ON SCHEMA public TO anon, authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- organizations
|
||||
-- authenticated (org_admin) lee solo su org (filtrado por RLS)
|
||||
-- ============================================================
|
||||
GRANT SELECT ON public.organizations TO authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- consent_versions
|
||||
-- anon lee el texto de consentimiento para mostrarlo en el form
|
||||
-- ============================================================
|
||||
GRANT SELECT ON public.consent_versions TO anon, authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- surveys
|
||||
-- anon: encuestas live (para renderizar el form)
|
||||
-- authenticated: sus encuestas en cualquier estado
|
||||
-- ============================================================
|
||||
GRANT SELECT ON public.surveys TO anon, authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- questions
|
||||
-- anon: preguntas de encuestas live
|
||||
-- authenticated: preguntas de sus encuestas
|
||||
-- ============================================================
|
||||
GRANT SELECT ON public.questions TO anon, authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- consent_records
|
||||
-- INSERT: al enviar el form
|
||||
-- SELECT: authenticated puede consultar (RLS filtra; analyst usa service_role)
|
||||
-- ============================================================
|
||||
GRANT INSERT ON public.consent_records TO anon, authenticated;
|
||||
GRANT SELECT ON public.consent_records TO authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- responses
|
||||
-- INSERT: al enviar el form
|
||||
-- SELECT: authenticated puede hacer el query (RLS devuelve 0 filas para org_admin)
|
||||
-- El acceso con datos reales es solo via agg_segment (SECURITY DEFINER)
|
||||
-- ============================================================
|
||||
GRANT INSERT ON public.responses TO anon, authenticated;
|
||||
GRANT SELECT ON public.responses TO authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- answers
|
||||
-- INSERT: al enviar el form
|
||||
-- SELECT: authenticated puede hacer el query (RLS devuelve 0 filas para org_admin)
|
||||
-- ============================================================
|
||||
GRANT INSERT ON public.answers TO anon, authenticated;
|
||||
GRANT SELECT ON public.answers TO authenticated;
|
||||
Reference in New Issue
Block a user