ixiclinicDocs
Desarrolladores

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:

  1. 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.
  2. Si no hay request.user, responde 401.
  3. Si el token es de superadmin (tenantId === '__saas__'), responde 403 (ver abajo).
  4. En cualquier otro caso, inyecta:
request.tenant = {
  id: request.user.tenantId,
  branchId: request.user.branchId,
};

Nota: La lista de prefijos públicos de tenant-scope debe 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

TokentenantIdPuede acceder a
Staff de tenantUUID realRutas de tenant scopeadas a ese tenantId
Superadmin (SaaS)__saas__Solo /api/saas/* (bloqueado en rutas de tenant)
Cliente (paciente/empresa)UUID del tenantSolo rutas /api/client/* (token marcado _client)

Siguiente: Autenticación.

On this page