Primer commit - Sprint 1

This commit is contained in:
markosbenitez
2026-05-31 04:33:07 -03:00
commit ef9e4bf998
22 changed files with 2515 additions and 0 deletions

View 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);

View 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;

View 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
)
);

View 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();

View 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;

View 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;