Aislamiento entre tenants
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
tenantIdsale 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:
- “Las mascotas de esta clínica” → filtra por
Pet.tenantId. - “Las mascotas de este dueño” → filtra por
Pet.userId, y el tenant del dueño no dice nada del animal.
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:
- Es opt-in y del usuario, no de la clínica: un flag en su propia fila que él controla.
- El listado excluye su propia clínica en vez de incluirla: son “los de fuera que se han abierto”, una lista distinta de la de casa, y mezclarlas haría que un fallo en el filtro pasase desapercibido.
- Es un endpoint aparte, no un parámetro del listado normal. Una rendija con nombre propio se
revisa; un
?includeExternal=truese olvida.
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.