Diseño de Sistemas Multi-Tenant que No se Rompen a Escala
Cómo diseñar arquitecturas multi-tenant con aislamiento real. Tres capas de aplicación, alcance jerárquico, patrones RBAC y lecciones de construir tres sistemas multi-tenant diferentes.
Multi-Tenancy no es una decisión de base de datos
La mayoría de artículos sobre multi-tenancy empiezan con la pregunta de la base de datos: base de datos compartida, esquema compartido o base de datos dedicada por tenant. Ese es el lugar equivocado para empezar. El modelo de base de datos es un detalle de implementación. La pregunta arquitectónica es: cómo aplicas el aislamiento en cada capa del sistema para que el tenant A nunca pueda ver, modificar ni afectar los datos del tenant B.
Hemos construido tres sistemas multi-tenant diferentes. Cada uno resolvió el aislamiento de forma diferente porque cada uno tenía restricciones distintas. Uno usa alcance jerárquico con cinco niveles de identidad. Otro usa RBAC plano con cuatro roles. Un tercero usa alcance basado en organizaciones con aislamiento por canales. El modelo de base de datos fue la decisión menos interesante en los tres casos.
Este artículo cubre los patrones arquitectónicos que hacen que multi-tenancy sea seguro a escala. Para contexto más amplio sobre cómo abordamos la arquitectura de sistemas, esa guía cubre nuestra metodología. Para ejemplos específicos de sistemas multi-tenant con IA, consulta nuestras guías sobre comercio agéntico y gobernanza de IA.
El Modelo de Aplicación de Tres Capas
El aislamiento de tenants debe aplicarse en tres capas. Si falta alguna, se filtran datos.
┌─────────────────────────────────────────────────┐
│ Capa 1: MIDDLEWARE DE API │
│ Cada petición autenticada y acotada │
│ tenant_id extraído del JWT/API key │
│ Inyectado en el contexto de la petición │
│ │
├─────────────────────────────────────────────────┤
│ Capa 2: FILTROS DE CONSULTA │
│ Cada consulta a la base de datos incluye │
│ tenant_id. Cada búsqueda acotada por tenant. │
│ Ninguna consulta se ejecuta sin contexto │
│ de tenant. │
│ │
├─────────────────────────────────────────────────┤
│ Capa 3: APLICACIÓN DE POLÍTICAS │
│ Llamadas a herramientas verificadas contra │
│ políticas del tenant │
│ Memoria del agente acotada por tenant + sesión │
│ Salida filtrada por reglas de visibilidad │
│ del tenant │
│ │
└─────────────────────────────────────────────────┘
Capa 1: Middleware de API
Cada petición entrante debe ser autenticada y acotada a un tenant antes de que llegue a cualquier lógica de negocio.
// El middleware extrae el contexto del tenant de cada petición
async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token provided' });
const decoded = await verifyJwt(token);
const tenant = await tenantStore.getById(decoded.tenant_id);
if (!tenant || tenant.status !== 'active') {
return res.status(403).json({ error: 'Tenant not found or suspended' });
}
// Inyectar contexto del tenant en la petición
req.tenantContext = {
tenantId: tenant.id,
channelId: decoded.channel_id,
role: decoded.role,
permissions: decoded.permissions,
};
next();
}
El contexto del tenant no es opcional. Cada manejador de ruta, cada método de servicio, cada consulta a la base de datos lo recibe. Si una función no tiene contexto de tenant, no puede acceder a datos acotados por tenant.
Capa 2: Filtros de Consulta
Cada consulta a la base de datos debe incluir el alcance del tenant. Esto no se aplica por convención ("acuérdate de añadir la cláusula WHERE"). Se aplica por arquitectura.
// Clase base de repositorio que fuerza el alcance por tenant
class TenantScopedRepository<T> {
async findMany(tenantId: string, filters: Partial<T>): Promise<T[]> {
return this.db.query({
TableName: this.tableName,
KeyConditionExpression: 'tenant_id = :tid',
FilterExpression: this.buildFilterExpression(filters),
ExpressionAttributeValues: {
':tid': tenantId,
...this.buildFilterValues(filters),
},
});
}
async findById(tenantId: string, id: string): Promise<T | null> {
const result = await this.db.get({
TableName: this.tableName,
Key: { tenant_id: tenantId, id },
});
return result.Item || null;
}
// No existe ningún método que consulte sin tenant_id
// Las consultas cross-tenant son arquitectónicamente imposibles
}
Para motores de búsqueda (OpenSearch, MeiliSearch, Elasticsearch), cada consulta incluye un filtro de tenant:
async function searchProducts(tenantId: string, channelId: string, query: string) {
return opensearch.search({
index: 'products',
body: {
query: {
bool: {
must: [{ match: { searchText: query } }],
filter: [
{ term: { tenant_id: tenantId } },
{ term: { channel_ids: channelId } },
],
},
},
},
});
}
Capa 3: Aplicación de Políticas
Más allá del acceso a datos, los tenants tienen distintos permisos para las acciones que pueden realizar. La capa de políticas los verifica antes de que cualquier acción se ejecute.
interface TenantPolicy {
tenant_id: string;
rules: PolicyRule[];
}
interface PolicyRule {
action: string; // "create_order", "export_data", "use_ai_agent"
effect: "allow" | "deny";
conditions?: {
max_value?: number;
allowed_channels?: string[];
require_approval?: boolean;
};
}
// Verificación de política antes de cualquier acción
async function checkPolicy(tenantId: string, action: string, params: any): Promise<boolean> {
const policy = await policyStore.getForTenant(tenantId);
const matchingRules = policy.rules.filter(r => r.action === action);
// Las reglas de denegación tienen prioridad
if (matchingRules.some(r => r.effect === 'deny')) return false;
// Sin regla de permiso coincidente = denegado (denegación por defecto)
const allowRule = matchingRules.find(r => r.effect === 'allow');
if (!allowRule) return false;
// Verificar condiciones
if (allowRule.conditions?.max_value && params.value > allowRule.conditions.max_value) {
return false;
}
return true;
}
Alcance Jerárquico vs Plano
Alcance Plano (SaaS Simple)
Cada recurso pertenece a exactamente un tenant. Sin subniveles.
Tenant A
├── Usuarios (owner, admin, member, viewer)
├── Productos
├── Pedidos
└── Configuración
Tenant B
├── Usuarios
├── Productos
├── Pedidos
└── Configuración
Ideal para: productos SaaS donde cada cliente es un espacio de trabajo aislado. Piensa en herramientas de gestión de proyectos, sistemas CRM, dashboards internos.
El alcance plano necesita cuatro niveles de roles:
| Rol | Permisos |
|---|---|
| Owner | Acceso completo, facturación, eliminar tenant |
| Admin | Gestionar usuarios, configuración, todos los datos |
| Member | Crear y editar datos propios, ver datos compartidos |
| Viewer | Acceso de solo lectura a datos compartidos |
Alcance Jerárquico (Plataformas Enterprise)
Los recursos se acotan a través de múltiples niveles. Cada nivel reduce la visibilidad.
Tenant (organización del comerciante)
└── Canal (tienda online, API, widget)
└── Vinculación de Proveedor (qué proveedores son visibles por canal)
└── Cliente (usuario final dentro de un canal)
└── Sesión (sesión de navegador/dispositivo)
└── Hilo del Agente (una conversación de IA)
Ideal para: plataformas de marketplace, comercio multi-marca, sistemas enterprise donde una organización tiene múltiples tiendas, canales de venta o marcas subsidiarias.
Cada nivel añade un filtro. Un producto visible en el Canal A no es necesariamente visible en el Canal B, incluso dentro del mismo tenant. Un cliente en el Canal A no tiene acceso a los datos del Canal B. Un hilo de agente en una sesión no puede ver conversaciones de otra sesión.
// Contexto jerárquico pasado a través de cada operación
interface TenantContext {
tenantId: string; // organización
channelId: string; // tienda online o canal de ventas
customerId?: string; // usuario final (si está autenticado)
sessionId?: string; // sesión de navegador
threadId?: string; // hilo de conversación de IA
}
// Consulta acotada a la jerarquía completa
async function getVisibleProducts(ctx: TenantContext) {
const channel = await channelStore.get(ctx.tenantId, ctx.channelId);
return productStore.findMany({
tenant_id: ctx.tenantId,
supplier_id: { $in: channel.visibleSupplierIds },
status: 'active',
});
}
Alcance Híbrido
Algunos sistemas necesitan alcance plano para la mayoría de recursos pero alcance jerárquico para funcionalidades específicas. Por ejemplo, una instalación de Vendure puede usar alcance plano (un tenant por tienda) pero alcance basado en canales para visibilidad de productos y precios.
// Alcance por canales de Vendure
async findByCustomer(ctx: RequestContext, customerId: number) {
return this.connection.getRepository(ctx, CiWishlist).find({
where: {
customerId,
channelId: ctx.channelId, // Alcance por canal dentro del tenant
},
});
}
Para más información sobre cómo implementamos el alcance por canales en Vendure, consulta nuestra guía de arquitectura de producción de Vendure.
Patrones de Auth y RBAC
Alcance de Tenant Basado en JWT
El token JWT lleva la identidad del tenant. Cada petición de API lo incluye.
// Estructura del payload JWT
interface TenantJwtPayload {
sub: string; // ID de usuario
tenant_id: string; // a qué tenant pertenece
channel_id?: string; // qué canal (si aplica)
role: string; // owner | admin | member | viewer
permissions: string[]; // permisos granulares
iat: number;
exp: number;
}
El tenant_id en el JWT es el mecanismo principal de alcance. Se establece en el momento del login y no puede cambiarse sin re-autenticarse. El backend lo extrae de cada petición y lo usa para acotar todo el acceso a datos.
Autenticación por API Key
Para comunicación máquina a máquina (integraciones ERP, servicios externos, webhooks), las API keys se mapean a tenants:
async function apiKeyMiddleware(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return next(); // pasar al siguiente: auth JWT
const keyRecord = await apiKeyStore.findByKey(apiKey);
if (!keyRecord || keyRecord.status !== 'active') {
return res.status(401).json({ error: 'Invalid API key' });
}
req.tenantContext = {
tenantId: keyRecord.tenantId,
channelId: keyRecord.channelId,
role: keyRecord.role,
permissions: keyRecord.permissions,
};
next();
}
Las API keys están acotadas por tenant. La rotación de claves no cambia la vinculación con el tenant. Los límites de tasa y los alcances de permisos son por clave, no por tenant.
Granularidad de Permisos
Los roles definen niveles amplios de acceso. Los permisos definen capacidades específicas:
const PERMISSIONS = {
// Gestión de productos
PRODUCT_READ: 'product:read',
PRODUCT_CREATE: 'product:create',
PRODUCT_UPDATE: 'product:update',
PRODUCT_DELETE: 'product:delete',
// Gestión de pedidos
ORDER_READ: 'order:read',
ORDER_CREATE: 'order:create',
ORDER_CANCEL: 'order:cancel',
ORDER_REFUND: 'order:refund',
// Funcionalidades de IA
AI_AGENT_USE: 'ai:agent:use',
AI_AGENT_CONFIGURE: 'ai:agent:configure',
AI_EXPORT: 'ai:export',
// Administración
USER_MANAGE: 'user:manage',
SETTINGS_MANAGE: 'settings:manage',
BILLING_MANAGE: 'billing:manage',
};
// Mapeo de roles a permisos
const ROLE_PERMISSIONS = {
owner: Object.values(PERMISSIONS),
admin: Object.values(PERMISSIONS).filter(p => p !== 'billing:manage'),
member: ['product:read', 'product:create', 'product:update', 'order:read', 'order:create', 'ai:agent:use'],
viewer: ['product:read', 'order:read'],
};
Qué Pasa Cuando el Alcance Falla
La forma más instructiva de entender por qué la aplicación en tres capas importa es ver qué se rompe cuando falta cada capa.
| Capa Faltante | Qué Ocurre | Ejemplo Real |
|---|---|---|
| Sin middleware de API | Cualquier petición con un JWT válido puede acceder a los datos de cualquier tenant adivinando tenant IDs | Un competidor extrae el catálogo de productos de tu cliente |
| Sin filtros de consulta | Un desarrollador se olvida la cláusula WHERE en un nuevo endpoint, datos cross-tenant se filtran | El dashboard de administración muestra todos los clientes de todos los tenants |
| Sin aplicación de políticas | Un tenant con plan "starter" accede a funcionalidades "enterprise" mediante llamadas directas a la API | Un tenant del plan gratuito exporta datos sin límite, saltándose las restricciones del plan |
La versión más aterradora: las tres capas funcionan para lecturas pero no para escrituras. El tenant A no puede ver los datos del tenant B, pero un bug en el endpoint de actualización permite que el tenant A sobrescriba los precios de producto del tenant B. Esto lo detectamos en pruebas. En producción habría sido catastrófico.
Pruebas de Aislamiento Multi-Tenant
Probar multi-tenancy requiere patrones de test específicos que la mayoría de suites de pruebas no cubren.
El Test de Acceso Cross-Tenant
Para cada endpoint, verifica que el tenant A no puede acceder a los datos del tenant B:
describe('Tenant isolation', () => {
it('tenant A cannot read tenant B products', async () => {
// Crear producto como tenant B
const product = await createProduct(tenantB.token, { name: 'Secret Product' });
// Intentar leerlo como tenant A
const response = await api.get(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
expect(response.status).toBe(404); // No 403, no 200 con datos vacíos
});
it('tenant A cannot update tenant B products', async () => {
const product = await createProduct(tenantB.token, { name: 'Original' });
const response = await api.patch(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantA.token}` },
body: { name: 'Hacked' },
});
expect(response.status).toBe(404);
// Verificar que el producto no fue modificado
const check = await api.get(`/products/${product.id}`, {
headers: { Authorization: `Bearer ${tenantB.token}` },
});
expect(check.body.name).toBe('Original');
});
});
Devuelve 404 (no 403) para intentos de acceso cross-tenant. Un 403 confirma que el recurso existe, lo cual es en sí una fuga de información.
El Test de Aislamiento de Búsqueda
Verifica que los resultados de búsqueda están acotados por tenant:
it('search results are tenant-scoped', async () => {
await createProduct(tenantA.token, { name: 'Widget Alpha' });
await createProduct(tenantB.token, { name: 'Widget Beta' });
await waitForSearchIndex();
const results = await api.get('/search?q=Widget', {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
expect(results.body.items).toHaveLength(1);
expect(results.body.items[0].name).toBe('Widget Alpha');
// Widget Beta no debe aparecer
});
El Test de Operaciones en Lote
Verifica que las operaciones en lote (exportaciones, importaciones, actualizaciones masivas) respetan los límites del tenant:
it('export only includes own tenant data', async () => {
const exportResult = await api.post('/export/products', {
headers: { Authorization: `Bearer ${tenantA.token}` },
});
const exportedIds = exportResult.body.products.map(p => p.id);
const tenantBProducts = await getAllProducts(tenantB.token);
const tenantBIds = tenantBProducts.map(p => p.id);
// Ningún ID de producto del tenant B en la exportación del tenant A
const overlap = exportedIds.filter(id => tenantBIds.includes(id));
expect(overlap).toHaveLength(0);
});
Para más información sobre cómo abordamos las pruebas en general, consulta nuestra guía de ingeniería de software.
Infraestructura Compartida vs Dedicada
| Modelo | Cuándo Usarlo | Compromisos |
|---|---|---|
| Todo compartido (una BD, un esquema) | SaaS con muchos tenants pequeños | Lo más barato. Lo más difícil de aislar. Riesgo de vecino ruidoso. |
| BD compartida, esquemas separados | Tenants medianos que necesitan aislamiento lógico | Buen aislamiento. Más complejidad en migraciones. |
| Bases de datos dedicadas | Tenants enterprise con requisitos de cumplimiento | Mejor aislamiento. Más caro. Más difícil de gestionar. |
| Clusters dedicados | Industrias reguladas (salud, finanzas) | Aislamiento completo. Coste más alto. Operaciones separadas por tenant. |
Para la mayoría de aplicaciones SaaS, todo compartido con aplicación a nivel de aplicación (el modelo de tres capas descrito arriba) es la elección correcta. Es más simple de operar, más barato de ejecutar, y si las capas de aplicación son correctas, igual de seguro.
La infraestructura dedicada se vuelve necesaria cuando los tenants tienen requisitos regulatorios que exigen aislamiento físico (por ejemplo, los datos deben residir en un país específico), o cuando la carga de trabajo de un tenant es tan grande que afecta a los demás (el problema del vecino ruidoso).
Errores Comunes
-
Tratar multi-tenancy como un problema de base de datos. El modelo de base de datos (compartido vs dedicado) es la decisión menos importante. El modelo de aplicación (tres capas) es la más importante.
-
Aplicar el aislamiento por convención. "Los desarrolladores deberían incluir siempre tenant_id en las consultas" no es una estrategia. Haz que sea arquitectónicamente imposible consultar sin contexto de tenant.
-
Devolver 403 para acceso cross-tenant. Devuelve 404. Un 403 confirma que el recurso existe, lo cual filtra información entre tenants.
-
No tener tests de acceso cross-tenant. Cada endpoint necesita un test que verifique que el tenant A no puede acceder a los datos del tenant B. Tanto para lecturas como para escrituras.
-
Olvidar el aislamiento del índice de búsqueda. Las consultas a la base de datos pueden estar acotadas, pero si el índice de búsqueda no se filtra por tenant, los resultados de búsqueda se filtran entre tenants.
-
Cachés compartidas sin prefijo de clave de tenant. Si tu clave de caché en Redis es
product:123, se comparte entre tenants. Usatenant_abc:product:123. -
Trabajos en segundo plano sin contexto de tenant. Un job programado que procesa "todos los pedidos pendientes" sin alcance de tenant procesa los pedidos de todos los tenants en un solo lote. Pasa el contexto de tenant a través de los payloads de los jobs.
-
Sin rate limiting por tenant. La importación masiva de un tenant no debería degradar el rendimiento para todos los demás tenants. Aplica rate limiting por tenant, no solo por IP.
Puntos Clave
-
La aplicación en tres capas es innegociable. Middleware de API, filtros de consulta y aplicación de políticas. Las tres. Cada petición, cada consulta, cada acción.
-
El alcance jerárquico maneja la complejidad enterprise. El alcance plano funciona para SaaS simple. Las plataformas enterprise necesitan alcance a nivel de tenant, canal, cliente, sesión e hilo.
-
Haz que el acceso cross-tenant sea arquitectónicamente imposible. No confíes en que los desarrolladores recuerden las cláusulas WHERE. Clases base de repositorio que requieren tenant_id como parámetro obligatorio.
-
Prueba el aislamiento explícitamente. Cada endpoint necesita un test de acceso cross-tenant. Para lecturas, escrituras, búsquedas, exportaciones y operaciones en lote.
-
Devuelve 404, no 403. Los intentos de acceso cross-tenant deben parecer que el recurso no existe, no que el usuario no tiene permiso.
-
Infraestructura compartida con aislamiento a nivel de aplicación funciona para la mayoría de casos. La infraestructura dedicada es para requisitos regulatorios o problemas de vecino ruidoso, no para seguridad.
Aplicamos estos patrones en nuestros servicios de IA, proyectos de software a medida y plataformas de comercio. Si estás diseñando un sistema multi-tenant, habla con nuestro equipo o solicita un presupuesto. También puedes explorar nuestra página de soluciones y nuestro enfoque de confianza y cumplimiento para ver cómo manejamos las garantías de aislamiento de tenants.
Temas cubiertos
Guías relacionadas
Guía Empresarial de Sistemas de IA Agéntica
Guia tecnica de sistemas de IA agentica en entornos empresariales. Descubre la arquitectura, capacidades y aplicaciones de agentes IA autonomos.
Leer guíaComercio Agéntico: Cómo Dejar que los Agentes IA Compren de Forma Segura
Cómo diseñar comercio iniciado por agentes IA con gobernanza. Motores de políticas, puertas de aprobación HITL, recibos HMAC, idempotencia, aislamiento de tenants y el Agentic Checkout Protocol completo.
Leer guíaLos 9 Puntos Donde Tu Sistema de IA Filtra Datos (y Cómo Sellar Cada Uno)
Un mapa sistemático de cada lugar donde se filtran datos en sistemas de IA. Prompts, embeddings, logs, llamadas a herramientas, memoria de agentes, mensajes de error, caché, datos de fine-tuning y handoffs entre agentes.
Leer guía¿Listo para construir sistemas de IA listos para producción?
Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.
Iniciar una conversación