Guía técnica

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.

28 de febrero de 202620 min de lecturaEquipo de Ingeniería Oronts

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:

RolPermisos
OwnerAcceso completo, facturación, eliminar tenant
AdminGestionar usuarios, configuración, todos los datos
MemberCrear y editar datos propios, ver datos compartidos
ViewerAcceso 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 FaltanteQué OcurreEjemplo Real
Sin middleware de APICualquier petición con un JWT válido puede acceder a los datos de cualquier tenant adivinando tenant IDsUn competidor extrae el catálogo de productos de tu cliente
Sin filtros de consultaUn desarrollador se olvida la cláusula WHERE en un nuevo endpoint, datos cross-tenant se filtranEl dashboard de administración muestra todos los clientes de todos los tenants
Sin aplicación de políticasUn tenant con plan "starter" accede a funcionalidades "enterprise" mediante llamadas directas a la APIUn 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

ModeloCuándo UsarloCompromisos
Todo compartido (una BD, un esquema)SaaS con muchos tenants pequeñosLo más barato. Lo más difícil de aislar. Riesgo de vecino ruidoso.
BD compartida, esquemas separadosTenants medianos que necesitan aislamiento lógicoBuen aislamiento. Más complejidad en migraciones.
Bases de datos dedicadasTenants enterprise con requisitos de cumplimientoMejor aislamiento. Más caro. Más difícil de gestionar.
Clusters dedicadosIndustrias 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

  1. 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.

  2. 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.

  3. Devolver 403 para acceso cross-tenant. Devuelve 404. Un 403 confirma que el recurso existe, lo cual filtra información entre tenants.

  4. 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.

  5. 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.

  6. Cachés compartidas sin prefijo de clave de tenant. Si tu clave de caché en Redis es product:123, se comparte entre tenants. Usa tenant_abc:product:123.

  7. 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.

  8. 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

arquitectura multi-tenantaislamiento de tenantsalcance de datosarquitectura SaaSdiseño multi-tenancyseguridad de tenantsRBAC multi-tenantaislamiento de datos

¿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