⏱️ Lectura: 15 min

Construir una aplicación SaaS implica responder una pregunta incómoda en cada request: ¿de qué cliente —de qué tenant— son estos datos? Quarkus Multitenancy es una extensión open source, ya disponible en su primera versión (0.1.0) bajo Quarkiverse y publicada en Maven Central, que resuelve esa pregunta de forma segura y reutilizable: identifica el tenant de cada petición (por cabecera, JWT verificado, cookie o path) y lo propaga de manera consistente hasta la capa de datos. En este tutorial la levantamos de cero, con proyectos que compilan y salida real de cada paso: primero un quick-start de cinco minutos, después una base de datos por tenant con Hibernate ORM, y al final una saga de cuatro microservicios con verificación de JWT en cada salto.

📑 En este artículo
  1. El problema: un tenant en cada request
  2. La idea central: estrategias y un modelo de tres estados
  3. Requisitos
  4. Parte 1 — Quick-start: resolución por cabecera en cinco minutos
  5. Parte 2 — Una base de datos por tenant con Hibernate ORM
  6. Parte 3 — JWT verificado: del header al token firmado
  7. Parte 4 — La saga completa: cuatro servicios, verificación en cada salto
    1. Levantar la saga
    2. Aislamiento entre tenants
    3. Tokens rechazados
  8. Discusión honesta: alcance y límites
  9. Conclusión
  10. Referencias

El problema: un tenant en cada request

En una aplicación multi-inquilino, varios clientes comparten la misma instancia del backend pero sus datos deben permanecer estrictamente aislados. La parte difícil casi nunca es la base de datos: es el camino que recorre la identidad del tenant desde que entra la petición HTTP hasta que se ejecuta la consulta. Si ese hilo se corta en algún punto —o peor, si una entrada inválida cae silenciosamente a un tenant por defecto— se filtran datos entre clientes.

La mayoría de los proyectos lo resuelven a mano: un filtro JAX-RS que lee una cabecera, una variable ThreadLocal, y validaciones repartidas por todos lados. Funciona hasta que deja de funcionar. Quarkus Multitenancy estandariza ese patrón: una sola forma de resolver el tenant, un contrato explícito sobre qué pasa cuando la resolución falla, y un punto único donde inyectar las reglas de seguridad.

La idea central: estrategias y un modelo de tres estados

La extensión separa dos responsabilidades. Primero, cómo se obtiene el tenant, a través de estrategias configurables que se prueban en cadena:

  • header — lee el identificador de una cabecera HTTP (por defecto X-Tenant).
  • jwt — extrae el tenant de un claim de un token JWT verificado (no de un token cualquiera).
  • cookie — lo toma de una cookie.
  • path — lo deriva de un patrón en la URL, como /t/{tenant}/....

Segundo, y más importante, qué resultado produce un resolver. En lugar de un Optional<String> ambiguo, cada resolución devuelve un tipo sellado TenantResolution con tres estados:

  • Resolved — se obtuvo un tenant válido; la cadena se detiene.
  • NotApplicable — el resolver no tenía nada que procesar (no había token, por ejemplo); se prueba la siguiente estrategia.
  • Rejected — el resolver actuó sobre la entrada y la encontró inválida. La petición se aborta con HTTP 401 y nunca cae al tenant por defecto.

Esa distinción es el corazón de la extensión. Cierra el agujero clásico en el que un JWT que no se puede verificar se trata como “ausente” y la petición termina ejecutándose contra el tenant equivocado. Con Rejected, una entrada presente-pero-inválida siempre se hace visible.

Requisitos

  • JDK 21 o superior
  • Maven 3.9+
  • Docker (para los ejemplos con base de datos y mensajería)

La instalación de la extensión es idéntica en Windows, macOS y Linux: se declara la dependencia del módulo HTTP, que arrastra el core de forma transitiva.

<dependency>
  <groupId>io.quarkiverse.multitenancy</groupId>
  <artifactId>quarkus-multitenancy-http</artifactId>
  <version>0.1.0</version>
</dependency>

Para la integración con base de datos sumá también quarkus-multitenancy-orm. Los artefactos ya están en Maven Central, así que cualquier proyecto Quarkus los resuelve sin pasos extra.

Parte 1 — Quick-start: resolución por cabecera en cinco minutos

Empecemos por el caso más simple. Creamos un proyecto Quarkus y le agregamos la extensión:

quarkus create app org.acme:multitenancy-demo
cd multitenancy-demo

Agregá la dependencia del módulo HTTP al pom.xml (el bloque XML de arriba). Después, la configuración declarativa en src/main/resources/application.properties:

quarkus.multi-tenant.http.enabled=true
quarkus.multi-tenant.http.strategy=header
quarkus.multi-tenant.http.header-name=X-Tenant
quarkus.multi-tenant.http.default-tenant=public

La extensión instala un filtro que se ejecuta antes que tus recursos, lee la cabecera X-Tenant y deja el resultado disponible en un bean de petición, TenantContext. Tu código solo lo inyecta:

package org.acme;

import java.util.Optional;

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;

@Path("/api/users")
@Produces(MediaType.APPLICATION_JSON)
public class UserResource {

    @Inject
    TenantContext tenantContext;

    @GET
    @Path("/tenant")
    public Optional<String> tenant() {
        return tenantContext.getTenantId();
    }
}

No hay parsing manual de cabeceras ni ThreadLocal a la vista. Levantamos el proyecto en modo desarrollo:

./mvnw quarkus:dev

Y lo probamos con curl:

$ curl -H "X-Tenant: acme" http://localhost:8080/api/users/tenant
"acme"

$ curl -H "X-Tenant: globex" http://localhost:8080/api/users/tenant
"globex"

$ curl http://localhost:8080/api/users/tenant
"public"

El último request, sin cabecera, cae al default-tenant configurado (public). Elegimos strategy=header de forma explícita a propósito: la extensión trae un encadenamiento por defecto que incluye jwt, y si lo dejás implícito en 0.1.0 verás una advertencia al arranque recordándote que jwt está activo sin que lo hayas pedido. Declarar la estrategia que realmente usás es la buena práctica.

Parte 2 — Una base de datos por tenant con Hibernate ORM

Leer el tenant es media historia; la otra mitad es que la consulta golpee la base correcta. El módulo quarkus-multitenancy-orm conecta el TenantContext con la multitenancy nativa de Hibernate ORM. Para verlo de punta a punta levantamos dos Postgres, uno por tenant, con docker-compose.yml:

services:
  tenant1-db:
    image: postgres:15
    environment:
      POSTGRES_USER: user1
      POSTGRES_PASSWORD: pass1
      POSTGRES_DB: tenant1
    ports:
      - "5433:5432"
    volumes:
      - ./init-scripts/init-tenant1.sql:/docker-entrypoint-initdb.d/init-tenant1.sql

  tenant2-db:
    image: postgres:15
    environment:
      POSTGRES_USER: user2
      POSTGRES_PASSWORD: pass2
      POSTGRES_DB: tenant2
    ports:
      - "5434:5432"
    volumes:
      - ./init-scripts/init-tenant2.sql:/docker-entrypoint-initdb.d/init-tenant2.sql

Con el mismo esquema en cada base (init-scripts/init-tenant1.sql e init-tenant2.sql):

CREATE SEQUENCE IF NOT EXISTS users_seq START 1;

CREATE TABLE IF NOT EXISTS users (
    id BIGINT DEFAULT nextval('users_seq') PRIMARY KEY,
    name VARCHAR(255),
    email VARCHAR(255)
);

La entidad es una Panache normal, sin nada especial de multitenancy:

@Entity
@Table(name = "users")
public class User extends PanacheEntity {
    public String name;
    public String email;
}

Y la configuración define un datasource por tenant más el modo de multitenancy de Hibernate:

quarkus.hibernate-orm.multitenant=DATABASE

quarkus.datasource.tenant1.db-kind=postgresql
quarkus.datasource.tenant1.jdbc.url=jdbc:postgresql://localhost:5433/tenant1
quarkus.datasource.tenant1.username=user1
quarkus.datasource.tenant1.password=pass1

quarkus.datasource.tenant2.db-kind=postgresql
quarkus.datasource.tenant2.jdbc.url=jdbc:postgresql://localhost:5434/tenant2
quarkus.datasource.tenant2.username=user2
quarkus.datasource.tenant2.password=pass2

Levantamos las bases y la app:

docker compose up -d
./mvnw quarkus:dev

Ahora el aislamiento es automático. Creamos un usuario en tenant1 y otro en tenant2, y cada uno aterriza en su propia base:

$ curl -X POST -H "X-Tenant: tenant1" -H "Content-Type: application/json" \
       -d '{"name":"Ana","email":"[email protected]"}' \
       http://localhost:8080/api/users
{"id":1,"name":"Ana","email":"[email protected]"}

$ curl -X POST -H "X-Tenant: tenant2" -H "Content-Type: application/json" \
       -d '{"name":"Beto","email":"[email protected]"}' \
       http://localhost:8080/api/users
{"id":1,"name":"Beto","email":"[email protected]"}

$ curl -H "X-Tenant: tenant1" http://localhost:8080/api/users
[{"id":1,"name":"Ana","email":"[email protected]"}]

El listado de tenant1 nunca ve a Beto. El mismo User.listAll() de Panache devuelve registros distintos según el tenant resuelto, sin un solo WHERE tenant_id = ? en tu código: Hibernate enruta a la base correcta a partir del TenantContext.

Parte 3 — JWT verificado: del header al token firmado

Las cabeceras sirven para entornos internos y pruebas, pero en producción el tenant suele venir dentro de un token. La estrategia jwt no confía en el token: lo verifica. La configuración exige una fuente de verificación (SmallRye JWT u OIDC):

quarkus.multi-tenant.http.enabled=true
quarkus.multi-tenant.http.strategy=jwt

mp.jwt.verify.publickey.location=publicKey.pem
mp.jwt.verify.publickey.algorithm=RS256
mp.jwt.verify.issuer=https://micros-lab.test/issuer

La extensión inyecta el JsonWebToken ya verificado y extrae el claim del tenant. Si el token no se puede verificar —firma inválida, emisor incorrecto, expirado o sin el claim requerido— el resolver devuelve Rejected y la petición muere con 401 antes de tocar tu lógica.

Hay además una red de seguridad al arranque: si activás strategy=jwt y olvidás configurar la verificación, la aplicación no levanta. Un validador de inicio falla de inmediato con el contrato faltante, en vez de arrancar en un estado inseguro. Para casos legítimos —tests, o un JsonWebToken producido fuera de SmallRye/OIDC— se desactiva explícitamente con quarkus.multi-tenant.http.jwt.skip-startup-check=true.

Parte 4 — La saga completa: cuatro servicios, verificación en cada salto

El caso real rara vez es un solo servicio. Para mostrarlo armamos una saga que corre en una sola máquina: un edge-gateway público recibe la petición, llama a orders-svc, que publica un evento en Kafka vía Reactive Messaging, que billing-svc consume para generar la factura. Un pequeño jwt-toolbox hace de IdP local que acuña los tokens. Cada salto HTTP corre la extensión con strategy=jwt y re-verifica la firma de forma independiente.

client ─┬─▶ jwt-toolbox  :8088    (acuña el JWT)
        │
        └─▶ edge-gateway :18080 ─▶ orders-svc :8090 ─▶ kafka(orders.created) ─▶ billing-svc :8100
                                                                                     │
                                                                                     ▼
                                                                                  facturas

El gateway es una fachada delgada. Cuando un request llega a este recurso, ya está autenticado y con tenant resuelto: cualquier petición sin un JWT válido fue rechazada aguas arriba con 401 por el filtro. El detalle clave es que reenvía la cabecera Authorization sin modificarla a los servicios internos:

@Path("/api")
public class GatewayResource {

    @Inject
    TenantContext tenantContext;

    @RestClient
    OrdersClient ordersClient;

    @POST
    @Path("/orders")
    public OrderView createOrder(
            @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization,
            CreateOrderRequest request) {
        requireTenant();
        return ordersClient.create(authorization, request);   // token reenviado tal cual
    }

    private String requireTenant() {
        String tenant = tenantContext.getTenantId().orElse(null);
        if (tenant == null || tenant.isBlank() || "public".equals(tenant)) {
            throw new WebApplicationException(Response.status(401).build());
        }
        return tenant;
    }
}

orders-svc recibe ese mismo token, lo verifica otra vez, persiste la orden bajo su tenant y emite el evento a Kafka. Eso es defensa en profundidad: un edge comprometido todavía no puede inyectar tenants falsos, porque cada servicio interno vuelve a verificar.

Levantar la saga

mvn -DskipTests package                  # genera target/quarkus-app por módulo
docker compose -p microslab up --build   # 5 contenedores: redpanda + 4 servicios

Una vez que compose reporta todo sano, disparamos el camino feliz con un script que acuña el token, crea la orden y consulta la factura resultante:

./scripts/saga-happy.sh acme SKU-100 3

Esta es la salida real de la corrida (tokens y UUIDs preservados tal cual del run en vivo):

==> mint token (tenant=acme)
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL21...

==> whoami
tenant=acme

==> POST /api/orders {sku=SKU-100 qty=3}
{"orderId":"7e9fe19d-86d0-4397-af49-08702ba55753","tenant":"acme","sku":"SKU-100","quantity":3,"status":"CREATED","createdAt":1780493328909}

==> sleep 3s (let billing consume from kafka)

==> GET /api/orders
[{"orderId":"7e9fe19d-86d0-4397-af49-08702ba55753","tenant":"acme","sku":"SKU-100","quantity":3,"status":"CREATED","createdAt":1780493328909}]

==> GET /api/invoices
[{"invoiceId":"2b83c9ea-6d1a-4948-85d9-1e416b8a6660","tenant":"acme","orderId":"7e9fe19d-86d0-4397-af49-08702ba55753","amountCents":4500,"createdAt":1780493330243}]

El token RS256 firmado se resuelve en el gateway, viaja como Bearer a orders-svc, se re-verifica, se guarda bajo tenant=acme, se emite a Kafka orders.created, lo consume billing-svc y la factura (amountCents = quantity * 1500 = 4500) se materializa bajo el mismo tenant.

Aislamiento entre tenants

./scripts/saga-isolation.sh

Crea una orden para acme y otra para globex, y lista con cada token. Salida real:

==> acme lists orders (should see only A)
[{"orderId":"...","tenant":"acme",...},{"orderId":"...","tenant":"acme","sku":"A",...}]

==> globex lists orders (should see only G)
[{"orderId":"...","tenant":"globex","sku":"G","quantity":7,...}]

==> sleep 3s, then both query billing
acme    → [3 invoices, all tenant=acme]
globex  → [1 invoice  tenant=globex, amountCents=10500]

La lista de acme omite la orden de globex y viceversa; lo mismo del lado de facturación. La filtración solo podría ocurrir si el filtro JWT dejara escapar el tenant o si la clave del almacén por tenant se corrompiera: ninguna de las dos pasó.

Tokens rechazados

./scripts/saga-rejects.sh

Cinco sondas, todas devuelven 401. Salida real:

[no-auth]        HTTP 401 — {"error":"tenant not resolved"}
[wrong-issuer]   HTTP 401
[missing-claim]  HTTP 401
[expired]        HTTP 401
[malformed]      HTTP 401

El recorrido de cada una por la extensión:

Sonda Camino
no-auth Sin Authorization. La cadena no encuentra estrategia aplicable → tenant public → el recurso rechaza public como marcador no autenticado.
wrong-issuer iss apunta a un emisor falso. Mismatch con mp.jwt.verify.issuerRejected → 401 (sin fallback).
missing-claim Token verificado pero sin el claim tenantRejected("tenant claim missing") → 401.
expired Firma válida pero exp en el pasado. SmallRye JWT lo rechaza → 401.
malformed Bearer not.a.jwt, imposible de parsear → Rejected → 401.

El punto crucial: las cuatro sondas con Bearer inválido devuelven Rejected y no caen al tenant por defecto. Ese es exactamente el agujero de “downgrade silencioso” que el estado sellado Rejected cierra.

Discusión honesta: alcance y límites

Ninguna herramienta es magia. Esta es una versión preview (0.1.0): la API puede evolucionar antes de la 1.0, así que conviene fijar la versión exacta mientras se estabiliza.

El límite más importante es conceptual: la extensión opera en la frontera HTTP. Cuando un tenant cruza hacia un bus de mensajería como Kafka, ya está fuera del filtro. En la saga lo transportamos como una cabecera x-tenant-id del mensaje:

{
  "topic": "orders.created",
  "key": "acme",
  "headers": [ { "key": "x-tenant-id", "value": "acme" } ]
}

El consumidor extrae esa cabecera (preferida sobre el campo del payload) y loguea un mismatch si no coinciden, pero confía en que el productor la fijó bien. Un productor comprometido podría falsificar ese valor en el bus. Endurecerlo —un envelope CloudEvent que reverifique el JWT en el consumidor, mTLS y ACLs en el broker, o un patrón outbox con vistas por tenant— queda explícitamente fuera del alcance de esta versión y documentado como tal.

En el roadmap hay dos seguimientos ya acordados para el próximo ciclo: cambiar el encadenamiento por defecto a header,cookie (dejando jwt como opt-in explícito, a través de un ciclo de deprecación que ya emite la advertencia de arranque) y endurecer el identificador de tenant resuelto con límite de longitud, lista blanca de caracteres y mitigación de inyección en logs. Saber qué falta es parte de usar una herramienta con criterio.

Conclusión

Quarkus Multitenancy toma un patrón que casi todos terminamos escribiendo a mano —resolver y propagar el tenant— y lo convierte en una extensión con un contrato explícito sobre el caso que más importa: qué pasa cuando la resolución falla. El modelo de tres estados y la verificación real de JWT cierran el agujero del downgrade silencioso; la integración con Hibernate ORM extiende el aislamiento hasta la base de datos; y el ejemplo de microservicios demuestra que el hilo del tenant no se corta ni siquiera cruzando servicios, con verificación independiente en cada salto.

La extensión es open source bajo Quarkiverse y ya está disponible en Maven Central como io.quarkiverse.multitenancy:quarkus-multitenancy-http:0.1.0. El código, la demo lista para correr y la documentación están en el repositorio: github.com/quarkiverse/quarkus-multitenancy. Si construís SaaS sobre Quarkus, vale la pena clonar la demo, hacer docker compose up y ver cómo se siente el patrón cuando deja de ser código pegado con cinta.

Referencias


Andrés Morales

Desarrollador e investigador en inteligencia artificial. Escribe sobre modelos de lenguaje, frameworks, herramientas para devs y lanzamientos open source. Cubre papers de ML, ecosistema de startups tech y tendencias de programación.

0 Comentarios

Deja un comentario

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.