Ficha técnica
- Cliente
- Empresa privada (anónima). Red B2B curada de experiencias turísticas.
- Periodo
- Julio – agosto 2026
- Estado
- En producción
- Rol
- Arquitectura, construcción y reglas de seguridad
- Modelo
- Agencias aprobadas cotizan, reservan y pagan; no es un marketplace abierto
Contexto
Una empresa privada opera una red curada de experiencias turísticas: agencias de viajes previamente aprobadas cotizan, reservan y pagan actividades operadas por terceros. No cualquiera se registra y no cualquiera ve todo; ese filtro es justamente el producto.
Eso significa que en la misma base de datos conviven inquilinos que no deben verse entre sí, y proveedores cuyos datos comerciales no deben llegar al cliente final aunque aparezcan en el mismo flujo de reserva.
El sistema maneja además dinero: cotizaciones, reservas y pagos. Cualquier error de redondeo o de visibilidad no es un bug estético, es una disputa comercial.
El problema
En multi-tenant, la pregunta no es si el código filtrará datos entre inquilinos, sino cuándo.
Confiar el aislamiento a filtros escritos en cada consulta funciona hasta que alguien escribe una consulta nueva a las siete de la tarde de un viernes. No hace falta mala fe: basta con olvidar un filtro por inquilino, y en ese momento uno ve la operación de otro.
El mismo riesgo aparece en los documentos. Un voucher se arma con datos de la reserva, y en la reserva está el proveedor. Si el documento se genera «con lo que haya», la información del proveedor llega al cliente final sin que nadie lo haya decidido.
Y había una deuda concreta de la primera versión: los listados se truncaban en silencio a 200 filas. Quien consultaba un periodo con más movimientos recibía una respuesta que parecía completa y no lo era, que es la peor forma de estar equivocado.
Decisiones clave
Cada una de estas reglas está escrita como no negociable en el propio repositorio, para que sobreviva a quien las escribió.
El aislamiento se aplica en la base de datos
PostgreSQL tiene Row Level Security activo: es el motor el que decide qué filas puede ver cada inquilino, no la capa de aplicación.
Porque una regla que vive en el código depende de que todas las consultas la recuerden, y basta una para romperla. Si vive en la base, la consulta olvidadiza simplemente no obtiene los datos ajenos.
Una sola excepción, y acotada
Hay un rol de PostgreSQL con permiso para saltarse RLS, y su uso está restringido exclusivamente al adaptador de autenticación.
Toda regla de seguridad real necesita una puerta para el proceso que valida identidades. Lo importante es que esa puerta sea una, esté nombrada y no se preste para nada más; una excepción difusa acaba usándose «solo esta vez» en media aplicación.
El dinero es un entero
Todos los importes se guardan y se operan en centavos, nunca en coma flotante.
Los números con decimales en coma flotante no representan exactamente los importes de dinero y acumulan diferencias al sumar. En un sistema donde el cliente compara su total con el del proveedor, esa diferencia de un centavo termina en una llamada telefónica.
Que el voucher no filtre, comprobado por una prueba
Un test automatizado verifica que el documento que recibe el cliente jamás incluya datos del proveedor. Además, los campos sensibles van cifrados.
Es la misma lógica de todo el sistema: una regla que solo existe en la cabeza del equipo desaparece con el equipo. Escrita como prueba, cualquier cambio futuro que la rompa se detiene antes de llegar a producción.
Paginar por cursor, obligatoriamente
La segunda versión eliminó el truncado silencioso a 200 filas y exige paginación por cursor en los listados.
Un listado incompleto que se presenta como completo hace que alguien tome decisiones con la mitad de los datos y sin saberlo. Prefiero obligar a paginar —más trabajo para quien consume la API— que devolver una mentira cómoda.
Dónde vive cada garantía
Cada regla está puesta en la capa más profunda posible. Cuanto más abajo vive una garantía, menos formas hay de saltársela por accidente.
Row Level Security
PostgreSQL decide qué filas puede ver cada inquilino. Una consulta mal escrita no puede ver de más.
Un solo rol privilegiado
Existe un rol que puede saltarse RLS y está restringido al adaptador de autenticación.
Dinero en centavos
Nunca en coma flotante: los importes son enteros y no acumulan error de redondeo.
El voucher no filtra al proveedor
Un test automatizado verifica que el documento del cliente jamás muestre datos del proveedor.
Paginación por cursor obligatoria
Sustituye al truncado silencioso a 200 filas que tenía la primera versión.
La solución
La aplicación es Next.js 15 con TypeScript estricto sobre PostgreSQL, con autenticación propia que soporta segundo factor, caché en Redis y correo transaccional para las confirmaciones. Los documentos de reserva se generan desde la misma aplicación.
La parte interesante no es el marco de trabajo, sino dónde está puesta cada garantía: el aislamiento en el motor de base de datos, el dinero en enteros en el modelo de dominio, la confidencialidad del proveedor en una prueba automatizada, y la integridad de los listados en el contrato de la API.
Las pruebas van en dos niveles: unitarias para las reglas de dominio y de extremo a extremo para los flujos completos de cotización, reserva y pago.
Resultados
- En producción, con agencias aprobadas operando sobre inquilinos aislados por el propio motor de base de datos.
- Reglas no negociables documentadas y auditables: centavos, cifrado de campo, un único rol capaz de saltarse RLS.
- Prueba automatizada que impide que el voucher del cliente muestre datos del proveedor.
- El truncado silencioso de listados a 200 filas fue sustituido por paginación por cursor obligatoria.
Lo que aprendí
Que la seguridad multi-tenant se gana o se pierde en la capa más profunda. Todo lo que se resuelve en la base de datos deja de depender de la disciplina de quien escribe la siguiente consulta.
Que escribir las reglas como «no negociables» dentro del repositorio no es burocracia: es lo que hace que sigan vivas cuando el proyecto cambie de manos.
Y que una respuesta incompleta que aparenta ser completa es más peligrosa que un error. El error se ve; el listado truncado se cree.