Primer commit - Sprint 1
This commit is contained in:
8
supabase/.gitignore
vendored
Normal file
8
supabase/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
413
supabase/config.toml
Normal file
413
supabase/config.toml
Normal file
@@ -0,0 +1,413 @@
|
||||
# For detailed configuration reference documentation, visit:
|
||||
# https://supabase.com/docs/guides/local-development/cli/config
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "Motor_de_encuestas"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
# Controls whether new tables, views, sequences and functions created in the `public` schema by
|
||||
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
|
||||
# without explicit GRANTs. Leave unset today to preserve local behaviour. The implicit default
|
||||
# flips to `false` on 2026-05-30 to match the new cloud default, and the field is removed in
|
||||
# 2026-10-30 once the always-revoked behaviour is permanent. Set to `false` to opt in early.
|
||||
# auto_expose_new_tables = false
|
||||
|
||||
[api.tls]
|
||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||
enabled = false
|
||||
# Paths to self-signed certificate pair.
|
||||
# cert_path = "../certs/my-cert.pem"
|
||||
# key_path = "../certs/my-key.pem"
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# Maximum amount of time to wait for health check when starting the local database.
|
||||
health_timeout = "2m"
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 17
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
# [db.vault]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[db.migrations]
|
||||
# If disabled, migrations will be skipped during a db push or reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of schema files that describe your database.
|
||||
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||
schema_paths = []
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||
sql_paths = ["./seed.sql"]
|
||||
|
||||
[db.network_restrictions]
|
||||
# Enable management of network restrictions.
|
||||
enabled = false
|
||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs = ["0.0.0.0/0"]
|
||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs_v6 = ["::/0"]
|
||||
|
||||
# Uncomment to reject non-secure connections to the database.
|
||||
# [db.ssl_enforcement]
|
||||
# enabled = true
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
# Uncomment to configure local storage buckets
|
||||
# [storage.buckets.images]
|
||||
# public = false
|
||||
# file_size_limit = "50MiB"
|
||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||
# objects_path = "./images"
|
||||
|
||||
# Allow connections via S3 compatible clients
|
||||
[storage.s3_protocol]
|
||||
enabled = true
|
||||
|
||||
# Image transformation API is available to Supabase Pro plan.
|
||||
# [storage.image_transformation]
|
||||
# enabled = true
|
||||
|
||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.analytics]
|
||||
enabled = false
|
||||
max_namespaces = 5
|
||||
max_tables = 10
|
||||
max_catalogs = 2
|
||||
|
||||
# Analytics Buckets is available to Supabase Pro plan.
|
||||
# [storage.analytics.buckets.my-warehouse]
|
||||
|
||||
# Store vector embeddings in S3 for large and durable datasets
|
||||
[storage.vector]
|
||||
enabled = false
|
||||
max_buckets = 10
|
||||
max_indexes = 5
|
||||
|
||||
# Vector Buckets is available to Supabase Pro plan.
|
||||
# [storage.vector.buckets.documents-openai]
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
|
||||
# external_url = ""
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# JWT issuer URL. If not set, defaults to auth.external_url.
|
||||
# jwt_issuer = ""
|
||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||
# signing_keys_path = "./signing_keys.json"
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||
minimum_password_length = 6
|
||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||
password_requirements = ""
|
||||
|
||||
# Configure passkey sign-ins.
|
||||
# [auth.passkey]
|
||||
# enabled = false
|
||||
|
||||
# Configure WebAuthn relying party settings (required when passkey is enabled).
|
||||
# [auth.webauthn]
|
||||
# rp_display_name = "Supabase"
|
||||
# rp_id = "localhost"
|
||||
# rp_origins = ["http://127.0.0.1:3000"]
|
||||
|
||||
[auth.rate_limit]
|
||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||
email_sent = 2
|
||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||
sms_sent = 30
|
||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||
anonymous_users = 30
|
||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||
token_refresh = 150
|
||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||
sign_in_sign_ups = 30
|
||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||
token_verifications = 30
|
||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||
web3 = 30
|
||||
|
||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||
# [auth.captcha]
|
||||
# enabled = true
|
||||
# provider = "hcaptcha"
|
||||
# secret = ""
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 3600
|
||||
|
||||
# Use a production-ready SMTP server
|
||||
# [auth.email.smtp]
|
||||
# enabled = true
|
||||
# host = "smtp.sendgrid.net"
|
||||
# port = 587
|
||||
# user = "apikey"
|
||||
# pass = "env(SENDGRID_API_KEY)"
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
# subject = "Your password has been changed"
|
||||
# content_path = "./templates/password_changed_notification.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = false
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ .Code }}"
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# Configure logged in session timeouts.
|
||||
# [auth.sessions]
|
||||
# Force log out after the specified duration.
|
||||
# timebox = "24h"
|
||||
# Force log out if the user has been inactive longer than the specified duration.
|
||||
# inactivity_timeout = "8h"
|
||||
|
||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||
# [auth.hook.before_user_created]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||
[auth.mfa]
|
||||
# Control how many MFA factors can be enrolled at once per user.
|
||||
max_enrolled_factors = 10
|
||||
|
||||
# Control MFA via App Authenticator (TOTP)
|
||||
[auth.mfa.totp]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
|
||||
# Configure MFA via Phone Messaging
|
||||
[auth.mfa.phone]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
otp_length = 6
|
||||
template = "Your code is {{ .Code }}"
|
||||
max_frequency = "5s"
|
||||
|
||||
# Configure MFA via WebAuthn
|
||||
# [auth.mfa.web_authn]
|
||||
# enroll_enabled = true
|
||||
# verify_enabled = true
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth callback URL derived from auth.external_url.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||
email_optional = false
|
||||
|
||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||
[auth.web3.solana]
|
||||
enabled = false
|
||||
|
||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.firebase]
|
||||
enabled = false
|
||||
# project_id = "my-firebase-project"
|
||||
|
||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.auth0]
|
||||
enabled = false
|
||||
# tenant = "my-auth0-tenant"
|
||||
# tenant_region = "us"
|
||||
|
||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = false
|
||||
# user_pool_id = "my-user-pool-id"
|
||||
# user_pool_region = "us-east-1"
|
||||
|
||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.clerk]
|
||||
enabled = false
|
||||
# Obtain from https://clerk.com/setup/supabase
|
||||
# domain = "example.clerk.accounts.dev"
|
||||
|
||||
# OAuth server configuration
|
||||
[auth.oauth_server]
|
||||
# Enable OAuth server functionality
|
||||
enabled = false
|
||||
# Path for OAuth consent flow UI
|
||||
authorization_url_path = "/oauth/consent"
|
||||
# Allow dynamic client registration
|
||||
allow_dynamic_registration = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Supported request policies: `oneshot`, `per_worker`.
|
||||
# `per_worker` (default) — enables hot reload during local development.
|
||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||
policy = "per_worker"
|
||||
# Port to attach the Chrome inspector for debugging edge functions.
|
||||
inspector_port = 8083
|
||||
# The Deno major version to use.
|
||||
deno_version = 2
|
||||
|
||||
# [edge_runtime.secrets]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[analytics]
|
||||
enabled = true
|
||||
port = 54327
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# Experimental features may be deprecated any time
|
||||
[experimental]
|
||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||
orioledb_version = ""
|
||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
s3_host = "env(S3_HOST)"
|
||||
# Configures S3 bucket region, eg. us-east-1
|
||||
s3_region = "env(S3_REGION)"
|
||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
|
||||
# [experimental.pgdelta]
|
||||
# When enabled, pg-delta becomes the active engine for supported schema flows.
|
||||
# enabled = false
|
||||
# Directory under `supabase/` where declarative files are written.
|
||||
# declarative_schema_path = "./database"
|
||||
# JSON string passed through to pg-delta SQL formatting.
|
||||
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
|
||||
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;
|
||||
307
supabase/seed.sql
Normal file
307
supabase/seed.sql
Normal file
@@ -0,0 +1,307 @@
|
||||
-- ============================================================
|
||||
-- Seed: Etapa 1 — Encuesta de Mapeo de Cuidadores
|
||||
--
|
||||
-- Carga:
|
||||
-- 1. 1 organización de prueba
|
||||
-- 2. 1 consent_version v1 (scopes: operativo + macro_n1)
|
||||
-- 3. 1 encuesta "Mapeo de Cuidadores" (anónima, sensible, confidencial, Modo A)
|
||||
-- 4. 9 preguntas con códigos del mockup:
|
||||
-- B1 (yes_no), B2, B4, B5 — situación de cuidado (B5 is_sensitive=true)
|
||||
-- C1 (yes_no) — impacto laboral
|
||||
-- D1 (yes_no) — apoyo recibido
|
||||
-- G4 (single_choice) — frecuencia de estrés [condicional a C1=yes]
|
||||
-- H1 (multiple_choice, max_select=3) — apoyo preferido
|
||||
-- H3 (text_long) — comentarios libres
|
||||
-- 5. conditional_rules: B1 → show B2/B4/B5; C1 → show G4
|
||||
--
|
||||
-- UUIDs fijos para reproducibilidad y referencia desde tests.
|
||||
-- Nota: el mockup original no estaba disponible; los textos de pregunta
|
||||
-- son diseñados según el contexto "mapeo de cuidadores". Ver ETAPA_1_PROMPT.md.
|
||||
-- ============================================================
|
||||
|
||||
-- UUIDs de referencia:
|
||||
-- org: 10000000-0000-0000-0000-000000000001
|
||||
-- consent_v1: 20000000-0000-0000-0000-000000000001
|
||||
-- survey: 30000000-0000-0000-0000-000000000001
|
||||
-- q_B1..q_H3: 4000...0001 al 4000...0009
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Organización de prueba
|
||||
-- ============================================================
|
||||
INSERT INTO public.organizations (id, name, sector, size_bucket)
|
||||
VALUES (
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
'Empresa Demo S.A.',
|
||||
'Servicios',
|
||||
'100-500'
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Versión de consentimiento v1
|
||||
-- Scopes ofrecidos: operativo (dashboard empresa) + macro_n1 (estudio país anonimizado)
|
||||
-- NO incluye macro_n3 (atribuible) — decisión de v1 (ADR-001)
|
||||
-- ============================================================
|
||||
INSERT INTO public.consent_versions (id, version, body, scopes)
|
||||
VALUES (
|
||||
'20000000-0000-0000-0000-000000000001',
|
||||
'v1',
|
||||
'Esta encuesta es completamente anónima: no registramos tu nombre, número de documento '
|
||||
'ni ningún dato que permita identificarte personalmente. '
|
||||
'Tus respuestas se usarán con dos propósitos: '
|
||||
'(1) Ayudar a tu empresa a comprender la situación de cuidado de sus colaboradores y '
|
||||
'diseñar apoyos más adecuados (uso operativo, solo agregados). '
|
||||
'(2) Contribuir a un estudio de alcance nacional sobre la "economía del cuidado" en Paraguay, '
|
||||
'utilizando únicamente datos anonimizados e irreversiblemente desvinculados de cualquier empresa '
|
||||
'(Nivel 1 anonimizado). '
|
||||
'Una vez enviada tu respuesta, al ser anónima, no es posible retirarla ni modificarla. '
|
||||
'Podés elegir qué propósitos autorizás marcando las casillas a continuación.',
|
||||
'["operativo", "macro_n1"]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Encuesta: Mapeo de Cuidadores
|
||||
-- Anónima + Sensible (contiene datos de salud/discapacidad) + Confidencial
|
||||
-- Modo A: nosotros como responsables del tratamiento
|
||||
-- k_threshold=5: ninguna celda expuesta con n<5
|
||||
-- ============================================================
|
||||
INSERT INTO public.surveys (
|
||||
id, org_id, title, status,
|
||||
is_anonymous, data_sensitivity, security_class, controller_role,
|
||||
consent_version_id, k_threshold
|
||||
)
|
||||
VALUES (
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
'Mapeo de Cuidadores — Edición 2026',
|
||||
'draft',
|
||||
true, -- anónima (regla no negociable #2: respondent_id siempre NULL)
|
||||
'sensible', -- contiene preguntas de salud/discapacidad (B5)
|
||||
'confidencial', -- acceso restringido a la empresa y analistas internos
|
||||
'A', -- Modo A: InQ es responsable del tratamiento (v1 solo Modo A)
|
||||
'20000000-0000-0000-0000-000000000001',
|
||||
5 -- umbral k-anonimato por defecto
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Preguntas de la encuesta
|
||||
-- Orden: position define el flujo de presentación
|
||||
-- conditional_rules en el formato: [{ if_question, op, value, action, target }]
|
||||
-- if_question = code de la pregunta trigger
|
||||
-- target = code de la pregunta afectada
|
||||
-- ============================================================
|
||||
|
||||
-- B1: Pregunta disparadora — ¿Tenés persona a cargo?
|
||||
-- Si es "yes" → se despliegan B2, B4, B5
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, conditional_rules
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000001',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'B1',
|
||||
'yes_no',
|
||||
'¿Tenés a cargo a una o más personas con discapacidad permanente o adultos mayores '
|
||||
'(60 años o más) que requieran cuidados especiales de forma regular?',
|
||||
1,
|
||||
false,
|
||||
true,
|
||||
'[
|
||||
{"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B2"},
|
||||
{"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B4"},
|
||||
{"if_question": "B1", "op": "eq", "value": "yes", "action": "show", "target": "B5"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- B2: ¿Cuántas personas a cargo? (condicional a B1=yes)
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, options
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000002',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'B2',
|
||||
'single_choice',
|
||||
'¿Cuántas personas a tu cargo requieren cuidado especial?',
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
'[
|
||||
{"value": "1", "label": "1 persona"},
|
||||
{"value": "2", "label": "2 personas"},
|
||||
{"value": "3+", "label": "3 o más personas"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- B4: Relación con la persona a cargo (condicional a B1=yes)
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, options
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000003',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'B4',
|
||||
'single_choice',
|
||||
'¿Cuál es tu relación con la principal persona que cuidás?',
|
||||
3,
|
||||
false,
|
||||
false,
|
||||
'[
|
||||
{"value": "hijo_a", "label": "Hijo/a"},
|
||||
{"value": "padre_madre", "label": "Padre, madre o suegro/a"},
|
||||
{"value": "conyuge", "label": "Cónyuge o pareja"},
|
||||
{"value": "hermano_a", "label": "Hermano/a"},
|
||||
{"value": "otro_familiar", "label": "Otro familiar"},
|
||||
{"value": "otra", "label": "Otra relación"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- B5: Tipo de discapacidad/condición (condicional a B1=yes)
|
||||
-- is_sensitive=true: contiene datos de salud (categoría especial Ley 7593/2025)
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, options
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000004',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'B5',
|
||||
'multiple_choice',
|
||||
'¿Qué tipo de discapacidad o condición tiene la persona que cuidás? '
|
||||
'(Podés marcar más de una opción)',
|
||||
4,
|
||||
true, -- SENSIBLE: dato de salud/discapacidad (Ley 7593, categoría especial)
|
||||
false,
|
||||
'[
|
||||
{"value": "fisica", "label": "Física o motriz"},
|
||||
{"value": "visual", "label": "Visual"},
|
||||
{"value": "auditiva", "label": "Auditiva"},
|
||||
{"value": "mental", "label": "Mental o psicosocial"},
|
||||
{"value": "intelectual", "label": "Intelectual o cognitiva"},
|
||||
{"value": "am_sin_cert", "label": "Adulto mayor sin discapacidad certificada"},
|
||||
{"value": "otra", "label": "Otra condición"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- C1: Impacto laboral
|
||||
-- Si es "yes" → se despliega G4
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, conditional_rules
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000005',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'C1',
|
||||
'yes_no',
|
||||
'¿Sentís que tu rol de cuidador/a afecta tu asistencia, puntualidad o rendimiento '
|
||||
'en el trabajo?',
|
||||
5,
|
||||
false,
|
||||
true,
|
||||
'[
|
||||
{"if_question": "C1", "op": "eq", "value": "yes", "action": "show", "target": "G4"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- D1: Apoyo recibido de la empresa
|
||||
-- Declarada como trigger de sub-preguntas; sub-preguntas se agregan en iteración posterior
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, conditional_rules
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000006',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'D1',
|
||||
'yes_no',
|
||||
'¿Tu empresa te ofrece actualmente algún apoyo o beneficio pensado para personas que '
|
||||
'cuidan a familiares con discapacidad o adultos mayores?',
|
||||
6,
|
||||
false,
|
||||
true,
|
||||
'[]'::jsonb -- sub-preguntas a agregar en iteración posterior (mockup pendiente)
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- G4: Frecuencia de estrés (condicional a C1=yes)
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, options
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000007',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'G4',
|
||||
'single_choice',
|
||||
'¿Con qué frecuencia la situación de cuidado te genera preocupación o estrés '
|
||||
'durante tu jornada laboral?',
|
||||
7,
|
||||
false,
|
||||
false,
|
||||
'[
|
||||
{"value": "nunca", "label": "Nunca"},
|
||||
{"value": "raramente", "label": "Raramente (1-2 veces por mes)"},
|
||||
{"value": "a_veces", "label": "A veces (1-2 veces por semana)"},
|
||||
{"value": "frecuentemente","label": "Frecuentemente (casi todos los días)"},
|
||||
{"value": "siempre", "label": "Siempre (todos los días)"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- H1: Apoyo preferido (max_select=3)
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required, max_select, options
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000008',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'H1',
|
||||
'multiple_choice',
|
||||
'¿Qué tipos de apoyo de parte de la empresa valorarías más? '
|
||||
'Elegí hasta 3 opciones.',
|
||||
8,
|
||||
false,
|
||||
false,
|
||||
3, -- max_select=3 (mockup H1)
|
||||
'[
|
||||
{"value": "horario_flex", "label": "Horarios flexibles o trabajo híbrido"},
|
||||
{"value": "teletrabajo", "label": "Teletrabajo permanente"},
|
||||
{"value": "licencias", "label": "Licencias especiales para situaciones de cuidado"},
|
||||
{"value": "apoyo_eco", "label": "Apoyo económico o subsidio"},
|
||||
{"value": "orientacion", "label": "Orientación o asesoramiento profesional"},
|
||||
{"value": "grupos_apoyo", "label": "Grupos de apoyo entre pares"},
|
||||
{"value": "recursos_ext", "label": "Conexión con servicios o recursos externos"},
|
||||
{"value": "otro", "label": "Otro (especificar en comentarios)"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- H3: Texto libre — NO expuesto en el store analítico crudo (regla #4 CLAUDE.md)
|
||||
INSERT INTO public.questions (
|
||||
id, survey_id, code, type, prompt, position,
|
||||
is_sensitive, required
|
||||
)
|
||||
VALUES (
|
||||
'40000000-0000-0000-0000-000000000009',
|
||||
'30000000-0000-0000-0000-000000000001',
|
||||
'H3',
|
||||
'text_long',
|
||||
'Si querés compartir algo más sobre tu situación o tenés sugerencias para la empresa, '
|
||||
'podés hacerlo acá. (Opcional — tu respuesta es anónima)',
|
||||
9,
|
||||
false,
|
||||
false
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
309
supabase/tests/01_schema_and_logic.test.sql
Normal file
309
supabase/tests/01_schema_and_logic.test.sql
Normal file
@@ -0,0 +1,309 @@
|
||||
-- ============================================================
|
||||
-- Tests pgTAP: Etapa 1 — Esquema, RLS, Constraints, k-anonimato
|
||||
--
|
||||
-- Ejecutar con: supabase test db
|
||||
-- Requiere: Docker + supabase start + extensión pgtap
|
||||
--
|
||||
-- Cubre las 6 validaciones mandatorias del ETAPA_1_PROMPT.md:
|
||||
-- 1. Migración desde cero sin errores (verificado al correr supabase db reset)
|
||||
-- 2. Esquema coincide con DATA_MODEL.md
|
||||
-- 3. RLS: org_admin no ve filas crudas de responses ni answers
|
||||
-- 4. Constraint: respondent_id en encuesta anónima → error
|
||||
-- 5. k-anonimato: agg_segment n=3 → suppressed; n=5 → valor real
|
||||
-- 6. Inmutabilidad: UPDATE/DELETE en consent_records → error
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT plan(25);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 1: Estructura del esquema (7 tablas + columnas clave)
|
||||
-- ============================================================
|
||||
|
||||
SELECT has_table('public', 'organizations', '1.1 tabla organizations existe');
|
||||
SELECT has_table('public', 'consent_versions', '1.2 tabla consent_versions existe');
|
||||
SELECT has_table('public', 'surveys', '1.3 tabla surveys existe');
|
||||
SELECT has_table('public', 'questions', '1.4 tabla questions existe');
|
||||
SELECT has_table('public', 'consent_records', '1.5 tabla consent_records existe');
|
||||
SELECT has_table('public', 'responses', '1.6 tabla responses existe');
|
||||
SELECT has_table('public', 'answers', '1.7 tabla answers existe');
|
||||
|
||||
-- respondent_id debe existir y ser nullable (reservado pero NULL en v1)
|
||||
SELECT has_column('public', 'responses', 'respondent_id',
|
||||
'1.8 responses.respondent_id existe (reservado)');
|
||||
SELECT col_is_null('public', 'responses', 'respondent_id',
|
||||
'1.9 responses.respondent_id es nullable');
|
||||
|
||||
-- función agg_segment debe existir
|
||||
SELECT has_function('public', 'agg_segment', ARRAY['uuid', 'text[]'],
|
||||
'1.10 función agg_segment(uuid, text[]) existe');
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 2: Constraint — respondent_id en encuesta anónima
|
||||
-- (Regla no negociable #2 de CLAUDE.md)
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_org_id uuid := '10000000-ffff-0000-0000-000000000001';
|
||||
v_cv_id uuid := '20000000-ffff-0000-0000-000000000001';
|
||||
v_survey_id uuid := '30000000-ffff-0000-0000-000000000001';
|
||||
v_cr_id uuid := '50000000-ffff-0000-0000-000000000001';
|
||||
BEGIN
|
||||
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_constraint_org');
|
||||
INSERT INTO public.consent_versions (id, version, body, scopes)
|
||||
VALUES (v_cv_id, '_test-cv-constraint', 'body', '["operativo"]'::jsonb);
|
||||
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class)
|
||||
VALUES (v_survey_id, v_org_id, '_test_anon_survey', true, 'sensible', 'confidencial');
|
||||
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
|
||||
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT throws_ok(
|
||||
format(
|
||||
'INSERT INTO public.responses (survey_id, company_id, respondent_id, consent_record_id)
|
||||
VALUES (%L, %L, gen_random_uuid(), %L)',
|
||||
'30000000-ffff-0000-0000-000000000001',
|
||||
'10000000-ffff-0000-0000-000000000001',
|
||||
'50000000-ffff-0000-0000-000000000001'
|
||||
),
|
||||
'23514', -- check_violation SQLSTATE
|
||||
NULL,
|
||||
'2.1 respondent_id en encuesta anónima → check_violation'
|
||||
);
|
||||
|
||||
-- Una response SIN respondent_id en encuesta anónima debe poder insertarse
|
||||
SELECT lives_ok(
|
||||
format(
|
||||
'INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket)
|
||||
VALUES (%L, %L, %L, %L)',
|
||||
'30000000-ffff-0000-0000-000000000001',
|
||||
'10000000-ffff-0000-0000-000000000001',
|
||||
'50000000-ffff-0000-0000-000000000001',
|
||||
'25-34'
|
||||
),
|
||||
'2.2 response sin respondent_id en encuesta anónima → permitido'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 3: Inmutabilidad de consent_records
|
||||
-- (ADR-003: evidencia permanente del consentimiento)
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_cv_id uuid := '20000000-eeee-0000-0000-000000000001';
|
||||
v_cr_id uuid := '50000000-eeee-0000-0000-000000000001';
|
||||
BEGIN
|
||||
INSERT INTO public.consent_versions (id, version, body, scopes)
|
||||
VALUES (v_cv_id, '_test-cv-immutable', 'body', '["operativo"]'::jsonb);
|
||||
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
|
||||
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT throws_ok(
|
||||
format(
|
||||
'UPDATE public.consent_records SET granted_at = now() WHERE id = %L',
|
||||
'50000000-eeee-0000-0000-000000000001'
|
||||
),
|
||||
'42501', -- insufficient_privilege SQLSTATE
|
||||
NULL,
|
||||
'3.1 UPDATE en consent_records → error (inmutabilidad)'
|
||||
);
|
||||
|
||||
SELECT throws_ok(
|
||||
format(
|
||||
'DELETE FROM public.consent_records WHERE id = %L',
|
||||
'50000000-eeee-0000-0000-000000000001'
|
||||
),
|
||||
'42501',
|
||||
NULL,
|
||||
'3.2 DELETE en consent_records → error (inmutabilidad)'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 4: k-anonimato — agg_segment
|
||||
-- k_threshold=5: n=3 debe estar suprimido; n=5 debe mostrar valor
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_org_id uuid := '10000000-dddd-0000-0000-000000000001';
|
||||
v_cv_id uuid := '20000000-dddd-0000-0000-000000000001';
|
||||
v_survey_id uuid := '30000000-dddd-0000-0000-000000000001';
|
||||
v_cr_id uuid;
|
||||
i int;
|
||||
BEGIN
|
||||
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_kanonimato_org');
|
||||
INSERT INTO public.consent_versions (id, version, body, scopes)
|
||||
VALUES (v_cv_id, '_test-cv-kanon', 'body', '["operativo"]'::jsonb);
|
||||
INSERT INTO public.surveys (
|
||||
id, org_id, title, is_anonymous, data_sensitivity, security_class, k_threshold
|
||||
)
|
||||
VALUES (v_survey_id, v_org_id, '_test_kanonimato_survey', true, 'comun', 'confidencial', 5);
|
||||
|
||||
-- Insertar 3 responses en el mismo segmento (25-34 / Salud)
|
||||
FOR i IN 1..3 LOOP
|
||||
v_cr_id := gen_random_uuid();
|
||||
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
|
||||
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
|
||||
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector)
|
||||
VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud');
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- n=3 → suppressed debe ser TRUE, n debe ser NULL
|
||||
SELECT ok(
|
||||
(SELECT suppressed
|
||||
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
|
||||
WHERE (segment->>'qi_age_bucket') = '25-34'),
|
||||
'4.1 agg_segment n=3 → suppressed=true'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT n
|
||||
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
|
||||
WHERE (segment->>'qi_age_bucket') = '25-34'),
|
||||
NULL::bigint,
|
||||
'4.2 agg_segment n=3 → n=NULL (celda suprimida)'
|
||||
);
|
||||
|
||||
-- Agregar 2 responses más → total 5
|
||||
DO $$
|
||||
DECLARE
|
||||
v_cv_id uuid := '20000000-dddd-0000-0000-000000000001';
|
||||
v_survey_id uuid := '30000000-dddd-0000-0000-000000000001';
|
||||
v_org_id uuid := '10000000-dddd-0000-0000-000000000001';
|
||||
v_cr_id uuid;
|
||||
i int;
|
||||
BEGIN
|
||||
FOR i IN 1..2 LOOP
|
||||
v_cr_id := gen_random_uuid();
|
||||
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
|
||||
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
|
||||
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket, qi_sector)
|
||||
VALUES (v_survey_id, v_org_id, v_cr_id, '25-34', 'Salud');
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- n=5 → suppressed debe ser FALSE, n debe ser 5
|
||||
SELECT ok(
|
||||
NOT (SELECT suppressed
|
||||
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
|
||||
WHERE (segment->>'qi_age_bucket') = '25-34'),
|
||||
'4.3 agg_segment n=5 → suppressed=false'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT n
|
||||
FROM public.agg_segment('30000000-dddd-0000-0000-000000000001', ARRAY['qi_age_bucket'])
|
||||
WHERE (segment->>'qi_age_bucket') = '25-34'),
|
||||
5::bigint,
|
||||
'4.4 agg_segment n=5 → n=5 (valor real visible)'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 5: RLS — org_admin no puede ver filas crudas
|
||||
-- Se simula el rol 'authenticated' con JWT claim app_role='org_admin'
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_org_a uuid := '10000000-aaaa-0000-0000-000000000001';
|
||||
v_cv_id uuid := '20000000-aaaa-0000-0000-000000000001';
|
||||
v_srv_id uuid := '30000000-aaaa-0000-0000-000000000001';
|
||||
v_cr_id uuid := gen_random_uuid();
|
||||
v_resp_count bigint;
|
||||
v_ans_count bigint;
|
||||
BEGIN
|
||||
-- Setup: crear org, encuesta y una response
|
||||
INSERT INTO public.organizations (id, name) VALUES (v_org_a, '_test_rls_org_a');
|
||||
INSERT INTO public.consent_versions (id, version, body, scopes)
|
||||
VALUES (v_cv_id, '_test-cv-rls', 'body', '["operativo"]'::jsonb);
|
||||
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class, status)
|
||||
VALUES (v_srv_id, v_org_a, '_test_rls_survey', true, 'comun', 'confidencial', 'live');
|
||||
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
|
||||
VALUES (v_cr_id, v_cv_id, '{"operativo": true}'::jsonb);
|
||||
INSERT INTO public.responses (survey_id, company_id, consent_record_id, qi_age_bucket)
|
||||
VALUES (v_srv_id, v_org_a, v_cr_id, '35-44');
|
||||
|
||||
-- Simular JWT de org_admin de v_org_a
|
||||
PERFORM set_config('request.jwt.claims',
|
||||
json_build_object('app_role', 'org_admin', 'org_id', v_org_a::text)::text,
|
||||
true
|
||||
);
|
||||
|
||||
-- Cambiar al rol authenticated (con RLS activo)
|
||||
SET LOCAL ROLE authenticated;
|
||||
|
||||
SELECT count(*) INTO v_resp_count FROM public.responses;
|
||||
SELECT count(*) INTO v_ans_count FROM public.answers;
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
-- Limpiar config
|
||||
PERFORM set_config('request.jwt.claims', '', true);
|
||||
|
||||
IF v_resp_count != 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'RLS FAIL: org_admin ve % filas crudas en responses (esperado: 0)',
|
||||
v_resp_count;
|
||||
END IF;
|
||||
|
||||
IF v_ans_count != 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'RLS FAIL: org_admin ve % filas crudas en answers (esperado: 0)',
|
||||
v_ans_count;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT pass('5.1 RLS: org_admin ve 0 filas crudas en responses');
|
||||
SELECT pass('5.2 RLS: org_admin ve 0 filas crudas en answers');
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 6: Verificación del seed
|
||||
-- ============================================================
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.questions
|
||||
WHERE survey_id = '30000000-0000-0000-0000-000000000001'),
|
||||
9,
|
||||
'6.1 Seed: encuesta tiene 9 preguntas'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.questions
|
||||
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
|
||||
AND jsonb_array_length(COALESCE(conditional_rules, '[]'::jsonb)) > 0),
|
||||
3,
|
||||
'6.2 Seed: 3 preguntas tienen conditional_rules (B1, C1, D1 declaradas)'
|
||||
);
|
||||
|
||||
-- B1 tiene exactamente 3 reglas condicionales (→ B2, B4, B5)
|
||||
SELECT is(
|
||||
(SELECT jsonb_array_length(conditional_rules)
|
||||
FROM public.questions
|
||||
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'B1'),
|
||||
3,
|
||||
'6.3 Seed: B1 tiene 3 reglas condicionales (despliega B2, B4, B5)'
|
||||
);
|
||||
|
||||
-- H1 tiene max_select=3
|
||||
SELECT is(
|
||||
(SELECT max_select FROM public.questions
|
||||
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND code = 'H1'),
|
||||
3,
|
||||
'6.4 Seed: H1 max_select=3'
|
||||
);
|
||||
|
||||
-- B5 es la única pregunta marcada is_sensitive=true
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.questions
|
||||
WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND is_sensitive = true),
|
||||
1,
|
||||
'6.5 Seed: exactamente 1 pregunta is_sensitive=true (B5)'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user