Tres alcaldías en la misma base sin filtrar datos: RLS en Postgres con NestJS y Next.js
Demo técnica propia: sistema multi-tenant de gestión pública con aislamiento a nivel de base de datos, no solo en la consulta. Si un bug de aplicación olvida el filtro, Postgres lo niega igual.
Este es un case concepto — una demo técnica construida por Crazy Diamond, sin cliente y sin plazo comercial. No es un sistema entregado a una alcaldía real, y no lo vamos a presentar como si lo fuera. Lo que prueba es dominio de arquitectura: multi-tenancy con aislamiento real, control de roles y despliegue en producción.
Por qué existe este case
El origen fue un trabajo de ERP de gestión pública para múltiples alcaldías, con 28 propuestas competidoras, cliente sin historial y alcance indefinido. La decisión fue no disputar el trabajo y construir el case propio en su lugar — porque la pregunta que traba ese tipo de contratación nunca es "¿sabes hacer CRUD?", es "¿sabes garantizar que la Alcaldía A jamás vea un dato de la Alcaldía B?".
La decisión técnica central
La multi-tenancy puede implementarse con un simple filtro por tenant en la consulta. Funciona — hasta el día en que alguien escribe un endpoint nuevo y olvida el WHERE. La fuga ocurre en silencio, sin error, sin log, sin que nadie lo note.
Aquí el aislamiento se hizo con Row-Level Security en Postgres, por tenant. La política vive en la base de datos, no en la aplicación. Si el código olvida el filtro, la base lo niega igual. Es la diferencia entre confiar en la disciplina de quien escribe el código y tener una garantía estructural.
No tendría sentido probar "multi-tenancy" con filtro solo en la consulta. El falso negativo silencioso es peor que el error visible.
Cómo se validó el aislamiento
Fueron tres escenarios de prueba, ejecutados tanto en la base de desarrollo local como contra el Postgres de producción vía conexión pooled: un gestor municipal no logra leer ni escribir en otro tenant; un auditor lee entre tenants pero no escribe en ninguno; y una petición sin contexto de sesión falla cerrada — no devuelve todo, devuelve nada.
Ese último punto es el que más suele pasar desapercibido. Un sistema mal configurado, sin contexto, devuelve la base entera. Este devuelve vacío.
Autenticación y roles
JWT con access token de 15 minutos y refresh token de 7 días rotativo, con detección de reuso. Durante el desarrollo, implementar el refresh rotativo reveló un bug real de transacción que fue encontrado y corregido — exactamente el tipo de problema que solo aparece cuando se implementa rotación de verdad, en vez de emitir un token nuevo y cruzar los dedos.
Tres roles vía guards: servidor (lectura), gestor (lectura y escritura solo en su propio tenant) y auditor (lectura entre tenants, sin escritura). Por encima de ellos, un super-admin con visión consolidada.
Qué se construyó
Módulo presupuestario con rubros, partidas y ejecución simplificada en el flujo compromiso → liquidación → pago, en ledger append-only. Módulo de recaudación tributaria con registro de contribuyentes, liquidación de tributo y estado de pago y morosidad.
Dashboard gerencial con KPIs, gráficos de recaudación contra presupuesto y morosidad, además de un comparativo entre tenants visible solo para el super-admin. Y un portal de transparencia público, solo lectura, con datos agregados por tenant.
La base corre con seis migrations pareadas — cada una con su rollback probado, en la vuelta completa hasta la primera. Seeds con tres alcaldías ficticias, ocho usuarios, seis partidas y seis liquidaciones tributarias.
Arquitectura de front y despliegue
El frontend usa arquitectura BFF: el navegador solo conversa con Next.js, que reenvía la cookie al backend del lado del servidor. Sin CORS, sin cookie cross-site, sin token expuesto en el navegador.
El backend NestJS fue refactorizado para correr como función serverless en Vercel, manteniendo el entrypoint local y Docker funcionando sin duplicar lógica. Todo en producción en un subdominio real, con regresión completa — login, roles, aislamiento, 404, portal público — validada contra el dominio en el aire, no solo contra entorno de prueba.
Qué no está incluido
RRHH y nómina, compras y licitaciones, protocolo y gestión documental, e integración bancaria real quedan fuera — son roadmap, no entrega. La elección fue profundidad en dos módulos completos en vez de superficie en seis.
Stack
NestJS · Next.js 15 App Router · TypeScript strict · PostgreSQL con Row-Level Security · Neon · Tailwind · shadcn/ui · Recharts · Swagger · Vercel
