Seguridad

Cómo verificamos Armazón y qué no garantizamos

Armazón aísla los datos de cada tenant con políticas RLS de Postgres y lo comprueba con tests pgTAP contra un Supabase local real. Eso no es una garantía absoluta: es software 0.x, sin auditoría externa, y en el camino hemos encontrado y corregido fallos de seguridad propios. Aquí están el método, los siete que hemos registrado, tal como los documentamos, y lo que queda fuera.

Última revisión: 4 de noviembre de 2026

Lo que hacemos

Aislamiento decidido en la base de datos, con pruebas

La separación entre tenants se resuelve en Postgres con RLS sobre tenant_id, no en el frontend. Los 16 paquetes llevan al menos un test pgTAP, y cada fallo de seguridad real que encontramos se escribe en un registro, incluidos los incómodos.

Lo que no prometemos

Ni ausencia de fallos ni un proyecto seguro por defecto

No hay auditoría externa ni certificaciones. Los tests demuestran lo que prueban, no que no haya más fallos. Y la seguridad de tu proyecto depende también de cómo configures Supabase: hay pasos manuales que no se pueden hacer por SQL.

Método

Cómo se verifica

Cuatro piezas: el aislamiento vive en la base de datos, cada paquete trae sus tests, los cambios son migraciones nuevas y todo se contrasta además contra un proyecto real.

1 · Aislamiento

RLS sobre tenant_id, leído del JWT

Las políticas RLS de cada paquete filtran por el tenant_id que viaja en los claims del token de sesión, junto con el rol y los permisos. Esos claims los estampa el Custom Access Token Hook de auth-core al iniciar sesión. La decisión de acceso la toma Postgres en cada consulta y no depende del frontend.

2 · Tests

pgTAP por paquete, contra un Supabase local real

Cada paquete lleva sus tests en supabase/tests/. Un script del repositorio levanta un Supabase local en Docker, aplica las migraciones del paquete y de sus dependencias, y ejecuta solo los tests de ese paquete, partiendo de cero cada vez.

3 · Qué cubren

Lo que un atacante intentaría primero

  • Aislamiento entre tenants, incluso con el permiso concedido en el tenant equivocado.
  • Escalada de privilegios y consistencia de roles y membresías.
  • Verificación de email, MFA y permisos de restablecimiento de credenciales.
  • Privilegios de ejecución de funciones para anon y authenticated.
  • Lo que puede y no puede hacer un visitante sin sesión (anon).
  • Tablas donde solo debe escribir la Edge Function con service role.
4 · Migraciones

Aditivas: lo aplicado no se edita

Una vez aplicada en algún proyecto real, una migración no se modifica. Un arreglo es siempre una migración nueva con número más alto. Por eso cada incidente de abajo cita la migración que lo corrigió. A 4 de noviembre de 2026 los 16 paquetes suman 97 ficheros de migración.

Además de pgTAP, los README de los paquetes documentan pruebas en vivo contra el proyecto Supabase de producción de Kaizenith (el que usan las demos), con roles y claims simulados, y una pasada del asesor de seguridad de Supabase tras aplicar las migraciones.

Las cifras, a 4 de noviembre de 2026

Paquetes con tests pgTAP
16de 16
Ficheros de test
27en supabase/tests/
Aserciones declaradas
191suma de los plan(N)
Entradas en el registro
7fallos de seguridad reales

Recuento propio sobre el repositorio, contando los ficheros supabase/tests/*.sql y sumando el plan(N) de cada uno. Que las aserciones estén declaradas no es lo mismo que decir que pasan en tu entorno: el resultado de cada ejecución real se documenta en el README de cada paquete.

Tests pgTAP por paquete, a 4 de noviembre de 2026
PaqueteFicheros de testAserciones declaradas
activity-log16
auth-core738
billing-core16
booking-core18
catalog-core18
consent-core17
customer-forms438
customer-portal319
entitlements-core19
i18n-core110
notifications17
payments-core17
reviews-core18
tasks-core17
tenant-storage16
webhooks-core17
Total27191
Registro de incidentes

Los siete fallos de seguridad que hemos registrado

Es el registro de vulnerabilidades y brechas de aislamiento reales que hemos encontrado y corregido en Armazón. Solo entran fallos que suponían, o habrían supuesto, acceso no autorizado, fuga de datos entre tenants o el salto de una comprobación de seguridad. Los falsos rechazos y los errores de redacción de un test no entran. Se añade una entrada cada vez que una verificación encuentra y corrige un fallo de esta categoría.

Cómo leerlo. «Proyecto real» es el proyecto Supabase de producción de Kaizenith, el que también usan las demos. Cuando una entrada dice «habría», el fallo se detectó antes de aplicarse y nunca existió así en ese proyecto. El registro no incluye fechas exactas ni un análisis de accesos durante las ventanas en que un fallo estuvo aplicado: dice qué estaba expuesto y cómo se corrigió.

Resumen de las siete entradas del registro
# Paquete Qué pasaba ¿Llegó a un proyecto real? Cómo se encontró
1 tenant-storage Listado de ficheros de Storage sin exigir documents.read Sí Al escribir el pgTAP que le faltaba al paquete
2 webhooks-core dispatch_webhook_event() sin comprobar el tenant de quien llama Solo entre dos aplicaciones de migración consecutivas Autorevisión antes de aplicar la migración
3 consent-core Vista current_consents sin security_invoker No: se creó ya con el arreglo Revisión propia durante la construcción
4 notifications RLS sin activar pese a que la migración reportó éxito Sí Consultando pg_class.relrowsecurity directamente
5 auth-core Funciones pensadas solo para triggers, ejecutables por anon y authenticated El registro no lo especifica Pasada de hardening durante la construcción
6 auth-core Tres funciones ejecutables por anon: revoke ... from anon no bastaba Sí Asesor de seguridad de Supabase, justo tras aplicar la migración
7 payments-core Tabla stripe_webhook_events diseñada sin RLS No: corregido antes de aplicar Revisión propia antes de aplicar la migración
Incidente 1 · tenant-storage

Listado de objetos de Storage sin exigir documents.read

Llegó a un proyecto real
Qué estaba expuesto
Cualquier miembro del staff de un tenant podía listar los nombres de fichero y las rutas de los objetos de Storage de su propio negocio llamando directamente a la API de Storage, aunque no tuviera el permiso documents.read. La tabla de metadatos (tenant_documents) sí exigía ese permiso; la política de storage.objects solo comprobaba el prefijo de tenant.
Cómo se encontró
Al escribir el pgTAP que le faltaba al paquete, en la primera ejecución de nuestro proceso de verificación sobre él. Hasta entonces solo tenía verificación manual documentada.
¿Llegó a un proyecto real?
Sí. Estuvo en el proyecto real y se corrigió también allí.
Cómo se corrigió
Migración 0004 de tenant-storage: añade has_permission('documents.read') a la política, igualándola con la de tenant_documents.
Cómo se verificó
En local con pgTAP (6/6) y en el proyecto real: sin el permiso, 0 objetos visibles; con él, los propios.
Incidente 2 · webhooks-core

dispatch_webhook_event() sin comprobar el tenant de quien llama

Solo entre dos migraciones consecutivas
Qué estaba expuesto
La primera versión de la función no comprobaba que el tenant_id recibido fuera el del propio llamador. Cualquier usuario autenticado habría podido encolar entregas falsas contra el endpoint de webhook real de otro negocio.
Cómo se encontró
Autorevisión antes de aplicar la migración, durante la construcción del paquete.
¿Llegó a un proyecto real?
Solo durante el intervalo entre dos aplicaciones de migración consecutivas, según el registro.
Cómo se corrigió
Migración 0003 de webhooks-core: toda llamada directa (pg_trigger_depth() = 0) exige jwt_tenant_id() = p_tenant_id o is_platform_admin().
Cómo se verificó
El despacho al propio tenant se acepta; el despacho directo contra el tenant de otro negocio se rechaza con el mensaje esperado.
Incidente 3 · consent-core

Vista current_consents sin security_invoker

Corregido antes de aplicar
Qué habría estado expuesto
Por defecto, una vista de Postgres se ejecuta con los privilegios de quien la creó, no de quien la consulta. Sin security_invoker = true, current_consents habría devuelto los consentimientos de todos los tenants a cualquier consultante, saltándose la RLS de la tabla subyacente.
Cómo se encontró
Revisión propia durante la construcción del paquete. No fue un fallo detectado después de aplicar la migración sin el arreglo.
¿Llegó a un proyecto real?
No. La vista se creó ya con el arreglo.
Cómo se corrigió
La vista se creó ya con security_invoker = true (migración 0003 de consent-core; la 0004 la recrea conservando esa opción).
Cómo se verificó
Sin permiso de lectura de consentimientos, la vista devuelve 0 filas; con el permiso, la fila propia del tenant. Hay un test pgTAP que lo comprueba.
Incidente 4 · notifications

RLS sin activar pese a que la migración reportó éxito

Llegó a un proyecto real
Qué estaba expuesto
notification_preferences, notification_templates y notification_log mostraban pg_class.relrowsecurity = false, aunque la migración 0002 ejecutaba explícitamente ENABLE ROW LEVEL SECURITY y la herramienta con la que se aplicó reportó éxito. Las políticas existían pero no se aplicaban. Cualquier usuario autenticado podría haber leído los datos de notificación de cualquier tenant.
Cómo se encontró
Consultando pg_class.relrowsecurity directamente, en vez de fiarse del «success» de la herramienta. El origen exacto del problema nunca se confirmó.
¿Llegó a un proyecto real?
Sí. Las tres tablas estuvieron en el proyecto real sin RLS efectiva hasta la migración 0003. El registro dice que cualquier usuario autenticado «podría haber leído» sus datos y no recoge accesos.
Cómo se corrigió
Migración 0003 de notifications: vuelve a ejecutar las mismas sentencias ENABLE ROW LEVEL SECURITY, que son idempotentes.
Cómo se verificó
relrowsecurity = true en las tres tablas; se auditó el resto del proyecto y no se encontraron más casos. El pgTAP del paquete incluye una comprobación de relrowsecurity como regresión.
Incidente 5 · auth-core

Funciones pensadas solo para triggers, ejecutables por anon y authenticated

Proyecto real: no especificado
Qué estaba expuesto
Supabase concede EXECUTE por defecto a anon y authenticated sobre cualquier función nueva en public. Varias funciones del paquete están pensadas para invocarse solo desde un trigger, nunca desde un cliente, y ven datos de todos los tenants al hacerlo. Sin revocar ese permiso por defecto, cualquier cliente podría haberlas llamado directamente.
Cómo se encontró
Pasada de hardening explícita durante la construcción del paquete base.
¿Llegó a un proyecto real?
El registro no lo especifica.
Cómo se corrigió
Migración 0010 de auth-core: revoke execute ... from anon, authenticated, public, explícito.
Cómo se verificó
Según el registro, el asesor de seguridad de Supabase quedó limpio tras el cambio.
Incidente 6 · auth-core

Tres funciones ejecutables por anon: revoke ... from anon no bastaba

Llegó a un proyecto real
Qué estaba expuesto
Un visitante sin sesión (anon) podía llamar a request_email_verification, get_mfa_status y can_manage_credentials: pedir un token de verificación de email a nombre de cualquier usuario, saber si un usuario cualquiera tiene MFA activo y consultar si un usuario puede gestionar las credenciales de otro. La migración 0019, que debía cerrarlo, solo hacía revoke execute ... from anon. Eso no revoca nada por sí solo: Postgres concede EXECUTE sobre una función nueva al pseudo-rol PUBLIC, y anon y authenticated lo heredan por esa vía. Hacía falta revocar de public explícitamente. El propio comentario de la 0019 afirmaba lo contrario de lo que hacía.
Cómo se encontró
Con el asesor de seguridad de Supabase contra el proyecto real, justo después de aplicar la 0019. No lo detectó ningún pgTAP, porque hasta entonces ningún test del paquete comprobaba privilegios de ejecución directamente.
¿Llegó a un proyecto real?
Sí. Estaba aplicado en el proyecto real cuando se detectó.
Cómo se corrigió
Migración 0020 de auth-core: revoca de public y concede de forma explícita a authenticated en las tres funciones.
Cómo se verificó
Con has_function_privilege en local y en el proyecto real: tras el arreglo, anon no puede ejecutar ninguna de las tres. El asesor quedó limpio de esos tres avisos. Se añadió un test pgTAP de grants de funciones (0007_function_grants) para que esta clase de fallo no vuelva a colarse sin que un test lo note.
Incidente 7 · payments-core

Tabla stripe_webhook_events diseñada sin RLS

Corregido antes de aplicar
Qué habría estado expuesto
La tabla de deduplicación de eventos de Stripe se diseñó asumiendo que solo la escribe el service role y que no necesitaba RLS. Era incorrecto: con los permisos por defecto de Supabase, cualquier usuario autenticado habría podido leerla y borrarla.
Cómo se encontró
Revisión propia antes de aplicar la migración.
¿Llegó a un proyecto real?
No. Nunca existió sin RLS en ningún proyecto real.
Cómo se corrigió
Se añadió una política solo para administradores antes de aplicar la migración; está en la propia migración 0001 de payments-core, junto con la activación de RLS.
Cómo se verificó
El registro no incluye una verificación específica para esta entrada.
Lecciones

Lo que cada fallo nos enseñó

Reglas que aplicamos desde entonces, cada una nacida de una entrada del registro.

  • →Revocar de public, no solo de anon. Postgres da EXECUTE al pseudo-rol PUBLIC en cada función nueva y los demás roles lo heredan. En auth-core, un test pgTAP comprueba hoy los privilegios de ejecución con has_function_privilege (incidentes 5 y 6).
  • →Las vistas se crean con security_invoker = true. Sin esa opción, la vista corre con los privilegios de su creador y se salta la RLS de la tabla de abajo. Lo evitamos al crear la vista y lo comprobamos contando filas sin permiso (incidente 3).
  • →Tras habilitar RLS, se comprueba pg_class.relrowsecurity. El «success» de una herramienta no prueba que la RLS esté activa. Desde la entrada 4, cada paquete nuevo verifica explícitamente ese valor justo después de aplicar la migración (incidente 4).
  • →Una regla de acceso en dos sitios son dos reglas. La tabla de metadatos exigía documents.read y la política de Storage no. Al corregirlo se igualaron y el test comprueba ambos sentidos: sin el permiso no se ve nada, con él se ve lo propio (incidente 1).
  • →Un tenant_id recibido por parámetro es entrada hostil. Una función no puede fiarse del tenant que le dicen: lo contrasta con el del JWT o exige ser administrador de plataforma (incidente 2). Donde el parámetro es un filtro sobre una tabla con RLS, la verificación documentada es que pedir el tenant de otro negocio devuelve 0 filas.
Límites, sin letra pequeña

Lo que no garantizamos

Lo que sigue es lo que un desarrollador que evalúa Armazón debería saber antes de apoyarse en él.

Estado del producto y de las pruebas

  • —Versión 0.x. Los 16 paquetes están en 0.x. Un incremento de patch (0.1.0 a 0.1.1) es una corrección que no rompe nada; un cambio incompatible sería como mínimo una versión minor, documentada en el CHANGELOG del paquete. Hasta ahora ninguno ha necesitado una migración de datos propia, pero en 0.x conviene tratarlos como si pudiera hacer falta.
  • —Sin auditoría externa. Armazón no ha sido auditado por terceros ni cuenta con certificaciones de seguridad. La verificación descrita arriba la hacemos nosotros mismos, y una revisión externa podría encontrar lo que no hemos visto.
  • —Los tests prueban lo que prueban. El incidente 6 lo ilustra: ningún test existente comprobaba privilegios de ejecución hasta que lo señaló el asesor de seguridad de Supabase. El registro es lo que hemos encontrado, no una prueba de que no haya más.
  • —Huecos de cobertura pgTAP documentados. Para lo añadido en la versión 0.2.0 de activity-log, booking-core, catalog-core, notifications, payments-core y tenant-storage, el CHANGELOG de cada paquete deja el pgTAP como pendiente: se verificó con SQL adversarial contra el proyecto real, que no sustituye a un test repetible. En customer-forms, dos ficheros de test figuran en su CHANGELOG como escritos pero no ejecutados en esa iteración.
  • —Cobros de Stripe. Los README de payments-core y billing-core documentan que no se probó un cargo ni un checkout real. Se probaron las Edge Functions (rechazo de llamadas inválidas o sin permiso), la RLS y, en billing-core, el ciclo de suspensión y reactivación simulado con service role.
  • —Los permisos viajan en el JWT. Se estampan al iniciar sesión y no se leen en vivo de la base de datos en cada petición, así que un cambio de permisos solo se aplica cuando el token se renueva. En las pruebas documentadas hizo falta cerrar sesión y volver a entrar.

Lo que depende de tu configuración de Supabase

Estos pasos los documentan los README de los paquetes. Ninguno se puede hacer solo con SQL, y la seguridad de tu proyecto final depende de que los completes.

  • 01Activar el Custom Access Token Hook a mano (auth-core): Authentication, Hooks, Custom Access Token, y seleccionar public.custom_access_token_hook. Hasta entonces, un login real no lleva tenant_id, app_role ni permissions en el JWT.
  • 02Activar la protección frente a contraseñas filtradas (auth-core). El asesor de seguridad de Supabase la marca desactivada por defecto en un proyecto nuevo, y se activa en Authentication, Policies, no por SQL.
  • 03Límites de registro e inicio de sesión. auth-core no construye rate limiting propio de signup o login: se apoya en los límites de Supabase Auth, que se configuran en el panel del proyecto.
  • 04Desplegar las Edge Functions y fijar sus secrets. Por ejemplo, MFA_ENCRYPTION_KEY antes de usar MFA o el restablecimiento de credenciales por un administrador, las claves de Resend para notifications y las de Stripe para payments-core y billing-core.
  • 05Cuidar la clave service role. El borrado final de una cuenta (RGPD) es una llamada de backend con service role, nunca una RPC pública. Esa clave debe vivir solo en tu backend.
  • 06Verificar los dominios personalizados. Un dominio propio de un tenant queda sin verificar hasta que un administrador de plataforma comprueba el DNS y lo marca a mano. No hay proceso automático.

Lo que Armazón no cubre

  • —Tu frontend y tu hosting. Armazón es backend. La interfaz que generes con los prompts o escribas tú, y dónde la alojes, quedan bajo tu responsabilidad.
  • —Verificación de propiedad del email en consent-core. Registrar un consentimiento para un email no confirma que quien llama sea su dueño: sirve para guardar el consentimiento de quien envía un formulario, no como mecanismo de autenticación.
  • —Evitar que un tenant se quede sin gestor. No hay una comprobación que impida eliminar la última membresía con permiso staff.manage.
  • —Cumplimiento normativo. Armazón aporta piezas técnicas, como el registro de consentimientos o la exportación de datos de usuario, pero no certifica que tu producto cumpla el RGPD ni ninguna otra norma.
  • —Lo que ya listamos como límites del producto. Sin cuenta de Stripe incluida, sin traducción automática, sin kit de componentes. Está en la sección «Lo que Armazón no hace» de la página principal.
Divulgación responsable

Cómo reportar una vulnerabilidad

Si crees haber encontrado un fallo de seguridad en Armazón, escribe a hola@kaizenith.es con el asunto «Seguridad». Ayuda que incluyas:

  • El paquete afectado y, si lo sabes, su versión o el número de migración.
  • Qué has visto y qué esperabas ver.
  • Cómo reproducirlo: el rol desde el que lo ejecutas y los claims del JWT, o el SQL o la llamada que lo dispara.
  • El impacto que crees que tiene: qué datos de qué tenant se verían afectados.

Te pedimos que reproduzcas el fallo en una instalación local o en tu propio proyecto de Supabase, y no contra las demos públicas, que corren sobre un proyecto en producción. Pedimos también que no accedas a datos que no sean tuyos y que no publiques los detalles antes de que haya una corrección disponible.

Qué te prometemos. Acusaremos recibo de tu mensaje, lo revisaremos y, si es un fallo real, lo corregiremos con una migración nueva y lo registraremos en el registro de incidentes de esta página.

Qué no te prometemos. No publicamos plazos de respuesta ni de corrección, porque no queremos comprometernos a algo que no podamos garantizar. Tampoco existe un programa de recompensas.

Si quieres seguir evaluando Armazón, mira las novedades para ver qué ha cambiado y en qué versión, o las preguntas frecuentes.