Multi-tenancy
Aislamiento de datos por tenantId, contexto de tenant inyectado en cada request y bloqueo de tokens de superadmin en rutas de tenant.
ixiclinic es una plataforma SaaS donde muchos laboratorios (tenants) comparten una sola base
de datos y una sola instancia del API. El aislamiento entre ellos no lo da PostgreSQL
(no hay un esquema por tenant): lo da una disciplina estricta en el código. Todo dato está
scopeado por tenantId y toda query debe filtrar por él.
El JWT lleva el contexto
El token de acceso del staff (ver Autenticación) transporta
exactamente cuatro campos de identidad más un meta opcional de impersonación. La forma vive en
apps/api/src/lib/jwt.ts:
export interface JwtPayload {
userId: string;
tenantId: string;
branchId: string;
role: string;
/** Presente cuando la sesión está impersonando o "actuando como" otro rol. */
impersonator?: ImpersonatorMeta;
}tenantId— el laboratorio al que pertenece la sesión. Es la frontera de aislamiento.branchId— la sucursal activa (la mayoría de los tenants tienen varias).role— el rol del usuario en ese tenant (gobierna el RBAC).userId— el usuario autenticado.
Como branchId se guarda dentro del token, el conmutador de sucursal del admin re-emite el
access token con la nueva sucursal (y el refresh la preserva entre renovaciones).
El plugin tenant-scope
El plugin apps/api/src/plugins/tenant-scope.ts se registra justo después del de auth
(ver el orden en apps/api/src/app.ts). Corre en un hook preHandler —es decir, después
de que el plugin de auth ya pobló request.user— y deja el contexto del tenant listo en
request.tenant:
declare module 'fastify' {
interface FastifyRequest {
tenant: { id: string; branchId: string };
}
}Su lógica, en orden:
- Si una ruta es pública (prefijos de auth, portal, salud, docs, SaaS, cliente, bridge, webhooks y las superficies públicas de la cola), no scopea nada y deja pasar.
- Si no hay
request.user, responde 401. - Si el token es de superadmin (
tenantId === '__saas__'), responde 403 (ver abajo). - En cualquier otro caso, inyecta:
request.tenant = {
id: request.user.tenantId,
branchId: request.user.branchId,
};Nota: La lista de prefijos públicos de
tenant-scopedebe reflejar la del plugin de auth (apps/api/src/plugins/auth.ts). Si agregas una ruta pública nueva, actualiza ambas o tendrás un 401/403 incoherente.
Cómo lo usa un handler
Los handlers nunca leen tenantId del body ni de la query: lo toman siempre de
request.tenant.id (poblado por el plugin) y lo pasan al service, que filtra la query.
Patrón típico (tomado de apps/api/src/modules/ecf/handlers.ts):
export async function getConfigHandler(request: FastifyRequest, reply: FastifyReply) {
const config = await ecfService.getConfigPublic(request.tenant.id);
return reply.send(config);
}Y en el service la query siempre lleva el eq(...tenantId, tenantId):
const [config] = await db
.select()
.from(ecfConfigurations)
.where(eq(ecfConfigurations.tenantId, tenantId))
.limit(1);Esta es la regla de oro del proyecto: toda lectura o escritura cruza por tenantId. Un
SELECT o UPDATE sin ese filtro es una fuga de datos entre laboratorios.
Sentinels de superadmin
La capa SaaS/plataforma (console.ixiclinic.com) no es un tenant. Para que su token no pueda hacerse pasar por un tenant, se firma con valores centinela:
tenantId: '__saas__'branchId: '__saas__'
tenant-scope los detecta y rechaza explícitamente cualquier intento de un token de
superadmin de tocar una ruta de tenant:
if (request.user.tenantId === '__saas__') {
return reply.status(403).send({
statusCode: 403,
error: 'Forbidden',
message: 'Superadmin tokens cannot access tenant routes',
});
}A la inversa, las rutas /api/saas/* exigen el centinela mediante el preHandler
requireSuperadmin (apps/api/src/plugins/require-superadmin.ts), que requiere a la vez
role === 'superadmin' y tenantId === '__saas__'. Pedir ambos evita que un token emitido
por un tenant (con tenantId real) que de algún modo cargue role: 'superadmin' alcance los
endpoints cross-tenant de la plataforma. El rol superadmin además está marcado como reservado
en apps/api/src/plugins/rbac-config.ts (RESERVED_ROLES) para que ningún tenant pueda
asignarlo a un usuario o rol propio.
Resumen de las fronteras
| Token | tenantId | Puede acceder a |
|---|---|---|
| Staff de tenant | UUID real | Rutas de tenant scopeadas a ese tenantId |
| Superadmin (SaaS) | __saas__ | Solo /api/saas/* (bloqueado en rutas de tenant) |
| Cliente (paciente/empresa) | UUID del tenant | Solo rutas /api/client/* (token marcado _client) |
Siguiente: Autenticación.