Skip to content

0038. Capa de plataforma (superadmin) cross-tenant con superficie de auth aislada

Estado

Aceptada.

Contexto

El sistema es multi-tenant: cada organización está aislada y organizationId se lee siempre del JWT (ver ADR-0001, ADR-0007). Faltaba una identidad de operador de la plataforma (superadmin) que opere por encima de las organizaciones para supervisión y soporte (métricas globales, listar orgs/usuarios, activar/desactivar una org). El riesgo central: esa capa salta el filtro por tenant, así que debía diseñarse sin abrir un agujero cross-tenant.

Decisión

  • Identidad: campo platformRole en User (enum owner | operator, extensible, nullable)
    • mustChangePassword. Enum y no booleano para preparar operadores con distintas capacidades sin migrar. Un usuario con platformRole no necesita OrganizationUser.
  • Bootstrap seguro, auto-desactivable: al arrancar (onModuleInit), si SUPERADMIN_EMAIL/ SUPERADMIN_PASSWORD están definidas y hay cero usuarios con platformRole, se crea/ promueve el owner con credencial temporal (mustChangePassword=true). Sin endpoint público de creación. Solo corre con cero operadores → idempotente.
  • Superficie de auth separada (/auth/platform/*): signin/refresh/signout/me/change-password. El access token lleva scope: platform y no lleva organizationId. Reusa JWT_ACCESS_SECRET: la separación con la sesión de tenant (scope: full) es por scope, no por secret — cada estrategia Passport (jwt-access / jwt-platform) rechaza el token del scope ajeno. Cookie de refresh con nombre y path propios (/api/v1/auth/platform).
  • Aislamiento verificado en ambos sentidos: token de tenant → /platform/* = 403 (autenticado pero prohibido); token de plataforma → superficie de tenant = 401. El PlatformGuard se aplica a toda la clase del controller: cero endpoints de plataforma sin guard. El refresh de plataforma re-verifica platformRole al rotar.
  • Módulo platform (supervisión): métricas, listar orgs/usuarios, PATCH activar/ desactivar org. Queries agregadas cross-tenant, sin filtro por organizationId.
  • Desactivar una org tiene efecto real (no es solo un flag): el signin y el refresh de tenant chequean Organization.isActive además de OrganizationUser.isActive, así que desactivarla bloquea a sus usuarios (401). El refresh pasó también a validar la membresía activa (antes no lo hacía).

Consecuencias

  • El refresh de plataforma reusa la tabla RefreshToken (sin columna de scope). Tradeoff evaluado: un usuario que sea a la vez owner y admin de una org podría, con su refresh de plataforma, obtener del endpoint de tenant una sesión de la org a la que ya pertenece — no es escalada (ya tiene ese acceso). Un usuario solo de plataforma (sin membresía) no obtiene sesión de tenant (el refresh de tenant exige membresía). La garantía dura (scope del access token + guards) se mantiene.
  • Cubierto por test/platform.e2e-spec.ts (14 casos): aislamiento en ambos sentidos, signin neutro, métricas, activar/desactivar (con efecto verificado en el signin de tenant), refresh con rotación, change-password limpia el flag.
  • La lógica sensible de refresh token (persistir hasheado, buscar el match activo, revocar) se centralizó en TokenService (issueRefreshToken/findActiveRefreshToken/ revokeRefreshToken) y la cookie de refresh en un factory (refresh-cookie.factory.ts), eliminando la duplicación entre las superficies de tenant y plataforma (regla backend-conventions.md: patrón repetido 2+ veces → extraer).
  • Backend-only: el frontend camaroneras_platform y la invitación de operadores adicionales quedan como slices siguientes (aditivos, el modelo platformRole ya lo soporta).
  • Documentado en plataforma/superadmin-flow.