Apariencia
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 enplatform-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.isSystemen vez deisDefault. 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:
- 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" coneditar/eliminarpero sincrear, que no cuenta para el quórum. - 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). Elcode-reviewde cierre de la tarea encontró que eso no alcanza: el choke point real donde se otorga o revocausuarios-plataforma:creares la matriz de un ROL (PlatformRolesService.setPermissions), no el usuario. Si todos los managers activos terminan compartiendo un mismo rol custom concrear(cada reasignación individual pasa el chequeo por-usuario porque los demás siguen activos en ese momento) y luego alguien le quitacreara 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 ensetPermissions, con test e2e dedicado.- No quedarse sin nadie que administre: no se puede desactivar ni cambiar el rol al último usuario activo con permiso
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 deOwner/Operator+ backfill del enum viejo → FK, con verificación de conteo antes de dropear la columna) y20260720120500_platform_user_active(columnaplatformActive). - Los roles de sistema se re-siembran en cada boot (
PlatformPermissionsService.onModuleInit, upsert idempotente) — igual que el catálogoModule/Actionde tenant se re-siembra, necesario para que sobrevivan a unTRUNCATE(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) ybackfillDefaultPermissions()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.md→platform-rbac-ui,platform-onboarding-tenants.