Skip to content

0048. RBAC de plataforma con tablas propias (no reuso del modelo de tenant)

Estado

Aceptada

Contexto

La capa de plataforma (superadmin, cross-tenant — ADR-0038) solo tenía un enum PlatformRole (owner | operator) en User, sin matriz de permisos ni forma de crear roles nuevos: cualquier operador autenticado podía llamar cualquier endpoint /platform/*. La tarea platform-rbac-backend agrega un RBAC real — roles creables, matriz módulo×acción — equivalente en capacidad al de tenant (Role/Permission, ver plataforma/rbac-flow.md), pero para la superficie de plataforma.

La pregunta de diseño: ¿reusar el modelo de tenant (Role, Permission, PermissionGuard) o construir uno propio y aislado?

Decisión

Tablas propias: PlatformRole + PlatformPermission, con su propio guard (PlatformPermissionGuard), su propio servicio (PlatformPermissionsService) y su propio namespace de cache Redis (perms:platform-role:* vs perms:role:* de tenant). No comparten nada con el RBAC de tenant.

Motivo concreto (no solo "por consistencia con el resto de la capa de plataforma"):Role.organizationId es un campo obligatorio en el schema, con cascada desde Organization (onDelete: Cascade). Reusar roles para plataforma habría exigido volver ese campo nullable — y esa invariante ("todo rol pertenece a una organización") es la base de la que dependen, implícita o explícitamente, todas las queries de tenant que filtran por organizationId. Debilitarla para ahorrarse dos tablas pequeñas era un mal cambio: el costo de la duplicación es bajo (dos tablas, un guard, un servicio — todos espejo directo de sus equivalentes de tenant) comparado con el riesgo de introducir un camino de fuga cross-tenant en código que hoy asume esa invariante sin comprobarla.

Esto además es coherente con el resto de la capa: guards, estrategias JWT y scope de token de plataforma ya están deliberadamente aislados de los de tenant (ADR-0038); un RBAC compartido habría sido la única excepción a ese principio.

Diferencias con el RBAC de tenant

  • Catálogo en código, no en BD. El RBAC de tenant tiene tablas Module/Action (permite… en teoría… que un módulo se registre en runtime). El de plataforma no: sus 4 módulos (organizaciones, usuarios-plataforma, roles-plataforma, metricas) viven en platform-rbac.constants.ts. Son módulos internos que cambian con el deploy del backend, nunca por tenant — no había razón para el nivel de indirección de una tabla.

  • isSystem en vez de isDefault. Tenant tiene roles "por defecto" (Administrador, Técnico, Bodeguero) que se pueden renombrar/editar libremente — solo no se pueden eliminar si tienen usuarios. Plataforma tiene roles de sistema (Owner, Operator) que son inmutables (no renombrar, no editar permisos, no eliminar) — el equivalente en plataforma de "borrar el último admin" sería mucho más grave (nadie puede administrar la plataforma entera, no solo una organización), así que se protege con una restricción más dura que la de tenant.

  • Dos invariantes que tenant no necesita:

    1. No quedarse sin nadie que administre: no se puede desactivar ni cambiar el rol al último usuario activo con permiso usuarios-plataforma:crear. Es el equivalente de "no quedarse sin admin" en tenant (UsersService.ensureNotLastAdmin), pero medido por permiso (crear), no por rol — deliberado: permite un rol "Editor" con editar/eliminar pero sin crear, que no cuenta para el quórum.
    2. No auto-escalada: un usuario no puede cambiar su propio rol de plataforma, ni editar los permisos del rol que él mismo tiene. Tenant no tiene esta restricción explícita (un admin de tenant sí puede tocar su propio rol) — en plataforma el radio de impacto de una auto-escalada es cross-tenant, así que se cerró aparte.

    La invariante 1 se implementó primero solo en PlatformUsersService (deactivate/ changeRole de un usuario puntual). El code-review de cierre de la tarea encontró que eso no alcanza: el choke point real donde se otorga o revoca usuarios-plataforma:crear es la matriz de un ROL (PlatformRolesService.setPermissions), no el usuario. Si todos los managers activos terminan compartiendo un mismo rol custom con crear (cada reasignación individual pasa el chequeo por-usuario porque los demás siguen activos en ese momento) y luego alguien le quita crear a ESE rol, la plataforma se queda sin managers sin que ningún chequeo por-usuario lo hubiera detectado. Se agregó PlatformPermissionsService.hasActiveManagerOutsideRole() y el mismo chequeo en setPermissions, con test e2e dedicado.

platformActive: desactivación propia, separada de status

User.status es del lado tenant (ciclo de vida de la cuenta: verificación, invitación, activo). Un usuario de plataforma puede además ser miembro de una organización (identidad dual, ver platform-bootstrap.service.ts sobre promoción de usuarios existentes) — reusar status para desactivar su acceso de plataforma habría bloqueado también su acceso de tenant, o viceversa. Se agregó platformActive: Boolean como campo independiente.

Corolario de seguridad (encontrado en la revisión de esta tarea, no en el diseño inicial): como el permiso se resuelve por rol y NO por platformActive, un guard que solo chequeara permisos dejaría pasar a un usuario recién desactivado mientras su access token siguiera vigente — incluyendo la posibilidad de que se auto-reactivara con ese mismo token. Se resolvió agregando el chequeo de platformActive a PlatformPasswordChangeGuard (que ya hacía un lookup en BD por request para mustChangePassword): la desactivación aplica en la siguiente request, no recién cuando el token expira. Por el mismo motivo, PlatformAuthService.changePassword (que no pasa por ese guard a propósito, para no bloquear a alguien que todavía tiene mustChangePassword=true) también quedó chequeando platformActive explícitamente — si no, un usuario desactivado podía seguir rotando su propia contraseña con el token todavía vigente.

Consecuencias

  • Migraciones 20260720120000_platform_rbac (tablas + seed de Owner/Operator + backfill del enum viejo → FK, con verificación de conteo antes de dropear la columna) y 20260720120500_platform_user_active (columna platformActive).
  • Los roles de sistema se re-siembran en cada boot (PlatformPermissionsService.onModuleInit, upsert idempotente) — igual que el catálogo Module/Action de tenant se re-siembra, necesario para que sobrevivan a un TRUNCATE (setup de tests e2e) y no solo a la migración inicial.
  • Un desarrollador que agregue un módulo nuevo de plataforma lo declara en platform-rbac.constants.ts (no en una tabla) y backfillDefaultPermissions() lo otorga a los roles de sistema existentes en el próximo boot.
  • Precondición de platform-onboarding-tenants (backlog): esa tarea decide qué operador puede dar de alta un tenant nuevo usando este mismo RBAC.

Corrección (2026-07-21, tarea platform-rbac-ui)

La verificación manual en navegador (backend real, no mocks) encontró que Operator había quedado sembrado con isSystem=false — la migración original tenía el valor invertido para ese rol (Owner bien, Operator mal), contradiciendo la afirmación de este ADR de que ambos son "roles de sistema". Ningún test automatizado lo detectó porque ninguno afirmaba Operator.isSystem puntualmente, y el TRUNCATE del setup de tests e2e borraba el dato incorrecto y lo re-sembraba bien vía seedSystemRoles() — la base de dev (nunca truncada) fue la única que arrastró el bug. Corregido en la migración, en el dato ya sembrado, y en seedSystemRoles(): el upsert ahora fuerza isSystem: true en update (no solo en create) para los nombres de rol de sistema — no hay ningún endpoint que exponga cambiar ese campo, así que un valor false ahí solo puede ser un bug de seed, nunca una divergencia legítima, y conviene que se autocorrija en cada boot.

Referencias

  • Tarea platform-rbac-backend (2026-07-20).
  • ADR-0038 — aislamiento de superficies del que este ADR es una extensión (RBAC, no solo auth/guards).
  • camaroneras_backend/src/modules/platform/platform-rbac.constants.ts, platform-permissions.service.ts, platform-roles.service.ts, platform-users.service.ts.
  • camaroneras_backend/prisma/migrations/20260720120000_platform_rbac/, 20260720120500_platform_user_active/.
  • camaroneras_docs/plataforma/superadmin-flow.md (contrato actualizado).
  • _planning/_backlog/backlog.mdplatform-rbac-ui, platform-onboarding-tenants.