Saltar a contenido

Multi-Tenancy en Camunda 8

Resumen: Camunda 8 implementa multi-tenancy con un flag global camunda.security.multi-tenancy.checksEnabled que habilita/deshabilita los checks de tenant. El sistema usa TenantCheck como filtro en queries, TenantAccess con semántica allowed/denied/wildcard, resolución transitiva de tenants en login (user → mapping rules → groups → roles → tenants), y DbTenantAwareKey en RocksDB para scoping de datos por tenant.


Control global: checksEnabled

La multi-tenancy en Camunda 8 se controla con un único flag de configuración:

camunda:
  security:
    multi-tenancy:
      checksEnabled: true  # false por defecto

Comportamiento con checksEnabled = false (default)

  • No hay aislamiento de tenant: todos los usuarios ven todos los recursos.
  • No hay tenant ID en las entidades: procesos, instancias, jobs, etc., no tienen tenant asociado.
  • TenantCheck no se aplica: los filtros de tenant no se inyectan en queries.
  • Uso: entornos single-tenant, desarrollo local, demos.

Comportamiento con checksEnabled = true

  • Todo recurso tiene un tenant ID: obligatorio en deployments, start de instancias, etc.
  • TenantCheck activo: las queries solo retornan recursos del tenant del usuario.
  • Default tenant: si no se especifica un tenant, se usa el tenant por defecto (<default>).
  • Validación estricta: operaciones sin tenant válido son rechazadas.

TenantCheck: filtro en queries

Concepto

TenantCheck es un componente que se inyecta en el pipeline de queries para filtrar resultados por tenant. Es un filtro que puede estar habilitado o deshabilitado según la configuración global.

Implementación

flowchart TD
    Query[Query entrante] --> Check{checksEnabled?}
    Check -->|NO| NoFilter[Ejecutar query<br/>sin filtro de tenant]
    Check -->|SÍ| TC[TenantCheck]
    TC --> Step1[1. Obtener tenants del SecurityContext<br/>del usuario actual]
    Step1 --> Step2[2. Generar filtro:<br/>WHERE tenantId IN allowedTenants]
    Step2 --> Step3[3. Inyectar en la query]
    Step3 --> Exec[Ejecutar query con filtro]

Interacción con autorización

El TenantCheck opera en conjunto con el modelo de autorización (ver concepts/authorization-model):

  1. Primero autorización: ¿tiene el usuario permiso para el tipo de recurso y operación?
  2. Después tenant check: de los recursos autorizados, ¿cuáles pertenecen a los tenants del usuario?
  3. Resultado: solo recursos que pasan ambos checks son visibles/accesibles.

TenantAccess: modelo de acceso

Tres niveles de acceso

El modelo de acceso de tenant tiene tres niveles:

ALLOWED

El usuario tiene acceso a tenants específicos, listados explícitamente:

TenantAccess {
    type: ALLOWED,
    tenantIds: ["tenant-a", "tenant-b"]
}
  • Solo recursos de tenant-a y tenant-b son visibles.
  • Deployments solo pueden hacerse a estos tenants.
  • Process instances solo pueden iniciarse en estos tenants.

DENIED

El usuario no tiene acceso a ningún tenant:

TenantAccess {
    type: DENIED,
    tenantIds: []
}
  • Ningún recurso es visible.
  • Todas las operaciones son rechazadas.
  • Usado como default cuando un usuario no tiene asignaciones de tenant.

WILDCARD

El usuario tiene acceso a todos los tenants:

TenantAccess {
    type: WILDCARD,
    tenantIds: ["*"]
}
  • Todos los recursos son visibles independientemente del tenant.
  • Puede desplegar y operar en cualquier tenant.
  • Típicamente asignado solo a administradores del sistema.

Resolución transitiva de tenants en login

Cuando un usuario se autentica, sus tenants se resuelven a través de una cadena transitiva que agrega tenants de múltiples fuentes:

flowchart LR
    User((User)) --> Direct[Tenants directos del usuario]
    User --> MR[Mapping Rules]
    MR --> MRT[Claims OIDC → match con rules<br/>→ tenants de la rule]
    User --> Groups
    subgraph Groups[Groups]
        GD[Grupos directos del usuario]
        GM[Grupos asignados via mapping rules]
    end
    Groups --> GT[Tenants de cada grupo]
    User --> Roles
    subgraph Roles[Roles]
        RD[Roles directos del usuario]
        RM[Roles asignados via mapping rules]
        RG[Roles asignados via grupos]
    end
    Roles --> RT[Tenants de cada rol]

Algoritmo de resolución

función resolverTenants(user, oidcClaims):
    tenants = Set()

    // 1. Tenants directos
    tenants.addAll(user.tenants)

    // 2. Mapping rules (si OIDC)
    si oidcClaims != null:
        matchingRules = evaluarMappingRules(oidcClaims)
        para cada rule en matchingRules:
            tenants.addAll(rule.tenants)
            user.addRoles(rule.roles)
            user.addGroups(rule.groups)

    // 3. Grupos → tenants
    para cada group en user.groups:
        tenants.addAll(group.tenants)
        user.addRoles(group.roles)  // roles heredados de grupos

    // 4. Roles → tenants
    para cada role en user.roles:
        tenants.addAll(role.tenants)

    return tenants

Ejemplo concreto

Configuración:
  - User "alice": grupo "engineering"
  - Mapping Rule: si claim "team"="platform" → rol "platform-dev"
  - Grupo "engineering": tenant "company-main", rol "developer"
  - Rol "platform-dev": tenant "platform-services"
  - Rol "developer": tenant "dev-sandbox"

Login de alice con OIDC claim team=platform:
  1. Directos: (ninguno)
  2. Mapping: team=platform → rol "platform-dev" → tenant "platform-services"
  3. Grupo "engineering" → tenant "company-main", rol "developer"
  4. Rol "developer" → tenant "dev-sandbox"

  Resultado: tenants = {"company-main", "platform-services", "dev-sandbox"}

DbTenantAwareKey en RocksDB

Scoping de datos por tenant

En el broker de Zeebe, los datos se almacenan en RocksDB. Para multi-tenancy, las keys de RocksDB incluyen el tenant ID como parte de su estructura:

DbTenantAwareKey {
    tenantId: String,    // prefijo de la key
    entityKey: Long,     // key del recurso
    columnFamily: CF     // column family de RocksDB
}

Estructura de la key

flowchart LR
    subgraph Key["Key layout en RocksDB"]
        direction LR
        T["tenantId<br/>(var)"]
        CF["columnFamilyTag<br/>(1 byte)"]
        EK["entityKey<br/>(8 bytes)"]
        T --- CF --- EK
    end

Queries con tenant scoping

Cuando se consultan datos en RocksDB con multi-tenancy habilitado:

  1. Prefix scan: se usa el tenantId como prefijo para escanear solo datos del tenant relevante.
  2. Point lookup: la key completa incluye el tenantId, por lo que un lookup solo retorna datos del tenant correcto.
  3. Iteration: los iteradores se acotan al rango del tenantId prefix.

Ventajas del scoping en key

  • Aislamiento a nivel de storage: los datos de diferentes tenants no se mezclan a nivel de bytes.
  • Rendimiento de scan: un prefix scan por tenant solo lee datos de ese tenant, sin filtrar post-lectura.
  • Compaction efficiency: RocksDB puede organizar datos por tenant en diferentes SST files.

Sin checksEnabled

Cuando multi-tenancy está deshabilitada, el tenantId en las keys es un string vacío o un valor default, efectivamente eliminando la dimensión de tenant del key space.


Impacto en componentes

Broker (Zeebe)

  • DbTenantAwareKey en todas las column families de RocksDB.
  • Validación de tenant ID en todos los comandos entrantes.
  • Tenant ID propagado en el log replicado.

Exporters

  • Los records exportados incluyen el tenantId.
  • Los indices de Elasticsearch/OpenSearch pueden particionarse por tenant.

Webapps (Operate, Tasklist)

  • Queries a Elasticsearch/OpenSearch incluyen filtro de tenant.
  • UI muestra selector de tenant para usuarios con acceso a múltiples tenants.
  • REST API acepta tenantId como parámetro en queries.

Connectors

  • Los connectors operan en el contexto de un tenant específico.
  • Las variables del connector incluyen el tenant ID del process instance.

Consideraciones para un MVP

  • Diferible: multi-tenancy es una feature avanzada que la mayoría de deployments iniciales no necesitan. Puede implementarse después.
  • Diseñar para el futuro: aunque no se implemente multi-tenancy inicialmente, incluir un tenantId en las entidades desde el inicio (con un valor default) facilita enormemente la implementación posterior.
  • Si se implementa: el enfoque de DbTenantAwareKey (tenant como prefijo de key) es el correcto para RocksDB y evita filtrado post-lectura costoso.
  • Simplificable: la resolución transitiva (user → groups → roles → tenants) puede simplificarse a asignación directa de tenants a usuarios para un MVP.
  • Simplificable: el flag global checksEnabled es un buen patrón — permite activar/desactivar multi-tenancy sin cambios de código.