Multi-tenancy y aislamiento de requests en Express.js | Nicolás Garzón
Multi-tenancy significa que una misma plataforma sirve a varios clientes aislando datos, permisos, configuración y operación. El tenant context debe derivarse de una fuente autenticada y acompañar toda la request.
En una aplicación SaaS, el identificador de tenant no es un filtro opcional. Forma parte de la identidad operativa:
Texto
Copiar actor autenticado
↓
resolver tenant activo
↓
verificar membership
↓
crear request context
↓
scopear queries, cache, logs, jobs y archivosUna fuga cross-tenant es un incidente de seguridad, aunque ocurra por una query sin WHERE.
Texto
Copiar acme.example.comPuede ser cómodo para UX, pero Host debe validarse contra infraestructura conocida.
Texto
Copiar /businesses/:businessId/ordersEs explícito, pero el cliente controla el valor y debe comprobarse contra memberships.
Puede incluir tenant activo o memberships verificadas. Los claims pueden quedar obsoletos.
Puede estar ligada a un tenant y scopes específicos.
Nunca confíes en un tenantId libre del body como fuente de autoridad.
TypeScript
Copiar type TenantContext = {
tenantId: string ;
actorId: string ;
membershipId: string ;
roles: string [ ] ;
} ;
response. locals. context = tenantContext; El contexto debe ser específico de la request, inmutable conceptualmente y creado después de autenticar.
Cada fila incluye tenant_id.
Ventajas: operación simple y eficiencia.
Costes: toda query debe scopearse; índices incluyen tenant; mayor impacto de errores.
Más aislamiento lógico y personalización.
Costes: migraciones, conexiones y observabilidad más complejas.
Aislamiento fuerte y operación independiente.
Costes: provisioning, backups, pooling y migraciones a gran escala.
La elección pertenece a System Design y PostgreSQL, pero Express debe propagar correctamente el contexto elegido.
TypeScript
Copiar orders. findById ( {
tenantId: context. tenantId,
orderId,
} ) ; Evita APIs de repository donde tenantId sea opcional para datos tenant-owned.
SQL
Copiar SELECT *
FROM orders
WHERE tenant_id = $1
AND id = $2 ; Texto
Copiar tenant:acme:order:123También incluye tenant en invalidación, locks distribuidos y rate-limit keys.
Construye rutas o object keys con tenant resuelto por servidor:
Texto
Copiar tenants/{tenantId}/orders/{orderId}/invoice.pdfNo uses nombres o paths del cliente directamente. Las signed URLs deben limitar recurso, acción y expiración.
Un job debe guardar tenant y actor relevante:
TypeScript
Copiar await queue. add ( 'send-order-email' , {
tenantId: context. tenantId,
orderId,
requestedBy: context. actorId,
} ) ; Al ejecutar, decide si revalidar permisos o actuar como sistema. Esa política debe ser explícita.
Incluye tenant ID interno y request ID, pero evita nombres o datos sensibles. Las métricas de alta cardinalidad pueden volverse costosas si usan tenant como label; quizá pertenezca a logs o tracing.
Row-Level Security puede reforzar aislamiento:
SQL
Copiar CREATE POLICY tenant_orders
ON orders
USING ( tenant_id = current_setting( 'app.tenant_id' ) ::uuid) ; Con pools, debes establecer y limpiar contexto dentro de una transacción o conexión reservada. Una variable de sesión que queda en el pool puede filtrar contexto a otra request.
TypeScript
Copiar const resolveTenant: RequestHandler = async ( request, response, next) => {
const actor = response. locals. actor;
const requestedTenant = tenantIdSchema. parse ( request. params. businessId) ;
const membership = await memberships. findActive ( {
actorId: actor. id,
tenantId: requestedTenant,
} ) ;
if ( ! membership) {
response. status ( 404 ) . json ( { code: 'BUSINESS_NOT_FOUND' } ) ;
return ;
}
response. locals. context = {
tenantId: requestedTenant,
actorId: actor. id,
membershipId: membership. id,
roles: membership. roles,
} ;
next ( ) ;
} ; El param selecciona; la membership autoriza; el contexto resultante es confiable.
Un usuario multi-tenant puede seleccionar organización. El cambio no debe aceptar un ID arbitrario sin validar membership. Invalida caches y evita conservar estado de la organización anterior en el cliente o session.
Nunca uses variable global para tenant. Usa contexto por request.
Resetea search_path, variables RLS y roles al liberar conexión.
Debe conservar el mismo tenant; no inferirlo desde datos ambiguos.
Distingue tablas globales y tenant-owned. No añadas tenant ficticio a todo.
Break-glass necesita scope, motivo, expiración y auditoría.
Los datos de un tenant deben poder identificarse y restaurarse sin mezclar otros.
tenantId opcional en repositories.
Resolver tenant antes de autenticar.
Cache key sin tenant.
Variables de conexión no limpiadas.
Jobs sin contexto.
Logs con nombre del tenant pero sin ID estable.
Tests solo del happy path.
Admin global como bypass silencioso.
Incluye pruebas de seguridad negativas:
actor de tenant A consulta ID de B
mismo resource ID en dos tenants
cache caliente de otro tenant
job con tenant incorrecto
conexión RLS reutilizada
archivo con path manipulado
cambio de organización
soporte break-glass
Las pruebas deben fallar si se elimina accidentalmente el filtro tenant.
Aislamiento fuerte aumenta operación y coste. Tablas compartidas simplifican infraestructura, pero exigen disciplina y defensa en profundidad. RLS reduce riesgo de queries incompletas, aunque añade contexto de conexión y complejidad de debugging.
Tenant context se deriva de identidad y membership verificadas.
Debe acompañar queries, cache, jobs, logs y archivos.
Nunca vive en variables globales.
Los pools requieren limpiar contexto de sesión.
Multi-tenancy es una propiedad transversal, no un middleware aislado.
¿Por qué tenantId del body no es confiable?
¿Qué fuga puede producir una cache key sin tenant?
¿Qué riesgo existe al usar RLS con pooling?
¿Cuándo un job debe revalidar permisos?
¿Por qué un repository tenant-owned no debería aceptar tenant opcional?
Ver respuestas
Porque el cliente puede cambiarlo para cruzar organizaciones.
Servir datos cacheados de otro tenant con el mismo ID.
Una conexión puede conservar variables de la request anterior.
Cuando la acción debe respetar permisos actuales y no una autorización histórica.
Porque facilita olvidar el scope y ejecutar una query global accidental.
CORS y same-origin policy explica una restricción específica del navegador que suele confundirse con seguridad del servidor.