Apariencia
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
platformRoleenUser(enumowner | operator, extensible, nullable)mustChangePassword. Enum y no booleano para preparar operadores con distintas capacidades sin migrar. Un usuario conplatformRoleno necesitaOrganizationUser.
- Bootstrap seguro, auto-desactivable: al arrancar (
onModuleInit), siSUPERADMIN_EMAIL/SUPERADMIN_PASSWORDestán definidas y hay cero usuarios conplatformRole, 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 llevascope: platformy no llevaorganizationId. ReusaJWT_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. ElPlatformGuardse aplica a toda la clase del controller: cero endpoints de plataforma sin guard. El refresh de plataforma re-verificaplatformRoleal rotar. - Módulo
platform(supervisión): métricas, listar orgs/usuarios,PATCHactivar/ desactivar org. Queries agregadas cross-tenant, sin filtro pororganizationId. - Desactivar una org tiene efecto real (no es solo un flag): el
signiny elrefreshde tenant chequeanOrganization.isActiveademás deOrganizationUser.isActive, así que desactivarla bloquea a sus usuarios (401). Elrefreshpasó 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 (reglabackend-conventions.md: patrón repetido 2+ veces → extraer). - Backend-only: el frontend
camaroneras_platformy la invitación de operadores adicionales quedan como slices siguientes (aditivos, el modeloplatformRoleya lo soporta). - Documentado en plataforma/superadmin-flow.