ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Aislamiento entre tenants

template-api
último cambio 2026-09-25

Todos los datos de todas las clínicas están en la misma base de datos, separados por una columna. No hay esquema por tenant ni conexión distinta: el aislamiento es una disciplina del código, y un where que falta no da error, da los datos de otro.

Esta página es la lista de sitios donde esa disciplina se aplica.

De dónde sale el tenant: del token, nunca del cliente

La regla, y es la única de la página que no tiene excepciones:

El tenantId sale del usuario autenticado, resuelto en el servidor. No de un parámetro, ni de una cabecera, ni de un campo del body.

export const CurrentUser = createParamDecorator((_: unknown, ctx: ExecutionContext): User => {
  const req = ctx.switchToHttp().getRequest();
  return req.auth!.user;
});

@CurrentUser() devuelve la entidad que el guard de autenticación ya cargó, así que user.tenantId es un dato del servidor. Cualquier endpoint que acepte el tenant por parámetro está a una petición con curl de servir los datos de otra clínica, y no hay revisión de código que lo vea siempre.

El corolario práctico: las claves derivadas también. La caché del censo es stats:census:{tenantId} y ese id viene del token, porque una clave mal construida enseña el padrón de una clínica a otra sin que nada falle.

Tres capas, tres responsabilidades distintas

El error típico es pensar que el guard resuelve el aislamiento. Resuelve el rol, que es otra cosa.

Capa Qué comprueba Con qué
Guard Que el rol puede llamar a esta ruta @UseGuards(RolesGuard) + @Roles(...)
Caso de uso Que el objetivo es de su clínica user.tenantId !== admin.tenantId
Consulta Que el listado no salga de su clínica where: { tenantId }

Un @Roles(Role.ADMIN) deja pasar al admin de cualquier clínica. Lo que impide que anonimice a un dueño ajeno es la comprobación del caso de uso, dos capas más abajo.

Para el panel de plataforma hay una excepción explícita y con nombre propio:

export const SuperadminRoute = () =>
  applyDecorators(ApiTags("Superadmin"), ApiBearerAuth(), UseGuards(RolesGuard), Roles(Role.SUPERADMIN));

Las rutas cross-tenant están todas en un módulo aparte y llevan ese decorador. No es azúcar: es lo que hace que “quién puede ver datos de varias clínicas” sea una lista corta y auditable en vez de una condición repartida por veinte controladores.

La respuesta es 404, no 403

Cuando el objetivo existe pero es de otra clínica:

if (!user || user.tenantId !== admin.tenantId) {
  throw new UserNotFoundError(targetUserId);
}

El mismo error para “no existe” y para “no es tuyo”, y a propósito. Un 403 confirma que ese id existe en alguna parte, y con ids de usuario o de mascota eso es un oráculo: se prueba una lista y se aprende qué existe. El 404 no distingue, que es justo lo que se quiere.

La excepción es dentro de la propia clínica. Ahí el 403 es correcto y además es más útil: la mascota existe, el usuario sabe que existe, y lo que falla es su permiso sobre ella.

assertReassignableBy(requester: User): void {
  if (requester.role === Role.USER) throw new ForbiddenError();
  if (requester.tenantId !== this.tenantId) throw new ForbiddenError();
}

Esta regla vive en la entidad, no en el caso de uso, porque es una propiedad de la mascota y no del endpoint que la toca — ver ¿métodos en la entidad, o fuera?.

Cuidado con las entidades que tienen dos tenants

Aquí es donde el aislamiento se vuelve interesante. Una mascota lleva tenantId propio y pertenece a un dueño que también tiene el suyo, y no siempre coinciden: el dueño se muda de clínica, o la clínica atiende a un animal de paso.

De ahí salen dos consultas que parecen la misma y responden a preguntas distintas:

Cuando una entidad puede desalinearse de su padre, el filtro de aislamiento tiene que decir sobre cuál de los dos aísla, y eso se escribe en el nombre del método del repositorio antes que en un comentario. Lo mismo pasa un servicio más allá: los ids de tenant del stack de notificaciones tienen exactamente este problema, con el tenant de envío y el de origen conviviendo en la misma fila.

El acceso cruzado consentido

El aislamiento total es el caso fácil. El difícil es cuando el producto necesita una rendija, y aquí la hay: un dueño puede permitir que otras clínicas vean su ficha para ser atendido de paso.

const where = {
  tenantId: { not: excludeTenantId },
  externalAccess: true,
  deletedAt: null,
};

Tres cosas que hacen esto seguro y que conviene copiar tal cual:

Qué probar

El aislamiento es lo único donde un test que falta se paga con datos de otro cliente:

Qué Por qué
Un objetivo de otra clínica responde 404 Es la comprobación del caso de uso, la que el guard no hace
Un listado sólo trae filas de su clínica El where que falta no da error
Un rol permitido de otra clínica también recibe 404 Es el caso que parece cubierto por el guard y no lo está
El tenant del token manda sobre cualquier dato de la petición Si algún endpoint lo acepta por fuera, aquí se ve
El listado de externos no incluye a los de casa Un fallo aquí se lee como “hay datos de más”, no como un error

Y una regla de revisión que vale más que los cinco tests: cada método nuevo de repositorio que devuelva una lista tiene que decir en su firma de qué tenant es. Si no lo dice, no está aislando.