15 KiB
Roles administrativos para staff interno de Horux 360
Estado: ✅ IMPLEMENTADO (2026-04-14) — MVP operativo con 5 roles (admin, TI, support, sales, finance). La sección final "Implementación ejecutada" resume qué quedó, qué se pospuso, y cómo se resolvió el rol TI añadido durante la implementación.
Problema
Actualmente, todo el poder administrativo transversal (ver todos los tenants, gestionar clientes, editar precios, emitir facturas manuales, consultar payments globales) está amarrado a un solo RFC hardcodeado:
// packages/shared/src/constants/roles.ts
export const GLOBAL_ADMIN_RFC = 'HTS240708LJA';
export function isGlobalAdminRfc(tenantRfc, role) {
return role === 'owner' && tenantRfc === GLOBAL_ADMIN_RFC;
}
Esto tiene 3 limitaciones:
- Un solo nivel de privilegio. O eres admin global (puedes todo) o no eres nadie transversal. No hay "soporte" (ver tenants pero no tocar facturación) ni "ventas" (crear clientes pero no tocar precios) ni "finanzas" (ver pagos, emitir facturas manuales, editar precios).
- Shared account o scalability issues. Para sumar una segunda persona del equipo Horux 360 con poderes admin, hoy tiene que (a) compartir login con el primer admin, o (b) crearle un user adicional dentro del tenant
HTS240708LJA. Esto funciona pero no escala y no permite permisos granulares. - Hardcode disperso. Cada vez que se agrega un endpoint admin, hay que recordar llamar
isGlobalAdmin()orequireGlobalAdmin(). Fácil olvidar. Fácil de equivocarse en la condición (p.ej. la bug que encontramos contenant-selectorque disparaba/tenantspara cualquier admin, no solo global).
Propuesta
Introducir roles de plataforma — una dimensión ortogonal al rol per-tenant (owner, cfo, etc.).
Schema propuesto
enum PlatformRole {
platform_admin // Todo: precios, clientes, facturas, suscripciones, roles de staff
platform_support // Ver todos los tenants, resolver tickets, NO tocar facturación ni precios
platform_sales // Crear/editar tenants (onboarding), ver suscripciones, NO editar precios
platform_finance // Ver payments, emitir facturas manuales, editar precios, exportar reportes fiscales
}
model UserPlatformRole {
id Int @id @default(autoincrement())
userId String @map("user_id")
role PlatformRole
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") // User.id de quien asignó (audit trail)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@index([role])
@@map("user_platform_roles")
}
Usuarios de staff pueden tener 0, 1 o varios UserPlatformRole. platform_admin es el superrol (implícitamente cubre todos los otros).
Autorización
Helpers nuevos en apps/api/src/utils/platform-admin.ts (reemplaza global-admin.ts):
export async function hasPlatformRole(userId: string, role: PlatformRole): Promise<boolean> { ... }
export async function canManageTenants(userId: string): Promise<boolean> {
// platform_admin O platform_sales O platform_support
}
export async function canEditPrices(userId: string): Promise<boolean> {
// platform_admin O platform_finance
}
export async function canEmitInvoices(userId: string): Promise<boolean> {
// platform_admin O platform_finance
}
export async function isPlatformStaff(userId: string): Promise<boolean> {
// cualquier platform_* role
}
Middleware nuevo en routes:
router.use(requirePlatformRole('platform_admin', 'platform_finance')); // OR
Migración
Paso 1: crear la tabla via Prisma schema.
Paso 2: poblar con los users actuales del tenant HTS240708LJA:
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT u.id, 'platform_admin', NOW()
FROM users u
JOIN tenants t ON u.tenant_id = t.id
JOIN roles r ON u.rol_id = r.id
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
ON CONFLICT DO NOTHING;
Paso 3: reemplazar isGlobalAdmin / isGlobalAdminRfc por llamadas a los nuevos helpers en todos los callsites (hay ~15-20 en backend, ~5-8 en frontend).
Paso 4: el frontend consume un endpoint nuevo GET /me/platform-roles que devuelve los roles del usuario autenticado. El frontend gatea el sidebar, páginas admin, etc. con eso.
Conservar el "admin global" como concepto UX
El término "admin global" sobrevive en UI/copy/docs — pero internamente corresponde a platform_admin. GLOBAL_ADMIN_RFC = 'HTS240708LJA' queda como referencia histórica para el tenant dueño de la plataforma, pero el check ya no es por RFC sino por rol de plataforma.
UI admin para gestionar staff
Página nueva /admin/staff (visible solo para platform_admin):
- Lista de users con sus
UserPlatformRole[] - Invitar nuevo staff (crear user + asignar roles)
- Editar roles existentes
- Quitar roles
- Audit log de cambios (opcional)
Alcance
| Área | Impacto |
|---|---|
| Schema | +1 tabla user_platform_roles, +1 enum PlatformRole |
| Seed | Migración idempotente que convierte users existentes del tenant HTS240708LJA a platform_admin |
| Backend utils | global-admin.ts → platform-admin.ts con helpers granulares |
| Backend routes | requireGlobalAdmin middleware reemplazado por requirePlatformRole(...) variante |
| Backend endpoints | Cada uno que hoy hace requireGlobalAdmin reclasificado: ¿admin? ¿finance? ¿support? ¿sales? |
| Frontend shared types | +PlatformRole enum compartido |
| Frontend store | +platformRoles: PlatformRole[] en auth-store |
| Frontend hook | usePlatformRole(role) para conditional render |
| Frontend pages | Sidebar/topnav, páginas admin actualmente con isGlobalAdminRfc usan el hook |
| Docs | CLAUDE.md sección roles, README changelog |
Estimación: ~2-3 días de implementación + testing.
Riesgos y consideraciones
- Compatibilidad con JWT existentes. Si los JWT hoy no llevan
platformRoles, todos los que estén activos al momento de deploy van a fallar el gate. Alternativas: (a) incluirplatformRolesen el claim al login, re-login forzado tras deploy; (b) resolverplatformRolesen cada request desde BD (costo extra por request pero sin re-login). isGlobalAdminRfcusado en frontend. El frontend hoy leeuser.tenantRfcdel store — no puede consultar el nuevo padrón desde ahí sin un round-trip al API. Patrón: al login incluirplatformRolesen la response y guardarlo en el store.- Superposición con rol per-tenant. Un user puede ser
platform_finance+ownerde su tenant. Ambos roles aplican en contextos distintos — NO son excluyentes. El código debe checar cada uno por su lado. - Custodia del rol
platform_admin. Inicialmente solo 1-2 personas. Protección contra bootstrap problem: si el único admin se queda sin acceso, hay que poder recrearlo via script (scripts/grant-platform-admin.ts).
Decisiones que posponemos hasta implementar
- ¿Dashboard específico para cada rol de plataforma o uno unificado con sections filtrados?
- ¿Notificaciones de seguridad cuando se agrega/quita un rol de plataforma?
- ¿Expiración automática de roles (ej: "acceso temporal de soporte por 7 días")?
- ¿Rate limits específicos por rol de plataforma?
Archivos que tocar cuando se implemente
apps/api/prisma/schema.prismaapps/api/prisma/seed.ts(migración idempotente)apps/api/src/utils/global-admin.ts→ renombrar aplatform-admin.tsapps/api/src/controllers/tenants.controller.ts,sat.controller.ts,subscription.controller.ts,usuarios.controller.ts,facturacion.controller.ts— reclasificar permisosapps/api/src/services/auth.service.ts— incluirplatformRolesen response del loginpackages/shared/src/constants/roles.ts—PlatformRoleenum, helperspackages/shared/src/types/auth.ts—JWTPayloadconplatformRoles?: PlatformRole[]apps/web/stores/auth-store.ts— campo nuevoapps/web/components/tenant-selector.tsx,sidebar*.tsx,admin/usuarios/page.tsx,clientes/page.tsx,configuracion/suscripcion/page.tsx— reemplazarisGlobalAdminRfcporhasPlatformRole(...)- Doc nuevo
docs/plans/YYYY-MM-DD-platform-admin-roles-implementation.mddocumentando la ejecución
Implementación ejecutada (2026-04-14)
Lo que se construyó
Schema:
- Enum
PlatformRolecon 5 valores:platform_admin,platform_ti,platform_support,platform_sales,platform_finance. Los primeros dos son supersets (implican todos los demás roles). - Tabla
user_platform_rolescon@unique([userId, role]), FK aUsercononDelete: Cascade, campocreatedBypara audit trail.
Helpers (apps/api/src/utils/platform-admin.ts):
hasPlatformRole(userId, role)— check específico, superset roles implican todohasAnyPlatformRole(userId, ...roles)— OR de varioscanManageTenants,canEditPrices,canEmitInvoicesManual,isPlatformStaff— atajos granularesgetPlatformRoles(userId)— lista completa (para JWT)isGlobalAdmin()— compat que checa supersets en tabla, fallback a RFC si vacía- Cache 5 min +
invalidatePlatformRolesCache(userId) - Constante interna
SUPERSET_ROLES = ['platform_admin', 'platform_ti']centraliza el concepto "superset"
Backward compat:
utils/global-admin.tsahora re-exportaisGlobalAdmindeplatform-admin.ts— todos los ~20 callsites existentes (controllers, services) siguen funcionando sin cambios.isGlobalAdminRfc()en shared acepta tercer parámetro opcionalplatformRoles. Los 8 callsites del frontend se actualizaron para pasaruser?.platformRoles.
JWT + Login:
JWTPayloadyUserInfoen shared incluyenplatformRoles?: PlatformRole[].auth.service.ts:login()yrefreshTokens()pueblan viagetPlatformRoles(userId).
Seed — backfill idempotente:
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT u.id, 'platform_admin'::"PlatformRole", NOW()
FROM users u JOIN tenants t ON u.tenant_id = t.id JOIN roles r ON u.rol_id = r.id
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
ON CONFLICT (user_id, role) DO NOTHING
Owners del tenant dueño pasan automáticamente a platform_admin. Re-correr seed no duplica.
Endpoints (/api/platform-staff/*):
GET /— lista staff con roles agrupados por userGET /search?q=...— busca candidatos por email/nombrePOST /grant{ userId, role }— asigna rol (upsert idempotente)POST /revoke{ userId, role }— quita rol. Protección: no te puedes quitar tu último rol superset (admin O TI) — evita bootstrap problem
UI /admin/staff:
- Gate doble: frontend (
isGlobalAdminRfcconplatformRoles) + backend (requirePlatformAdmin) - Tabla de staff con badges por rol (icon + color)
- Modal "Agregar staff" con búsqueda en vivo + selector de rol con preview
- Botón X en cada badge para revocar
- Card inferior con descripciones de los 5 roles
- Sidebar: nuevo item "Staff" con icono
Shield(solo admin global)
Audit:
platform_role.grantedyplatform_role.revokedse instrumentan automáticamente víaauditFromReq.
Rol platform_ti — agregado durante la implementación
El plan original contemplaba solo 4 roles (admin/support/sales/finance). Durante la ejecución se añadió platform_ti con los mismos permisos que admin pero separación semántica (trazabilidad distinta en audit).
Decisión de diseño: en vez de tratar admin como el único superset, se introdujo la constante SUPERSET_ROLES = ['platform_admin', 'platform_ti'] en backend y shared. Todos los checks de "¿es admin global?" ahora preguntan "¿tiene algún rol superset?". Esto permite:
- Agregar futuros supersets (ej:
platform_ceo) actualizando solo esa constante - Diferenciar en audit log: "quitó un rol = admin hizo" vs "quitó un rol = TI hizo"
- Protección "último superset" considera admin + TI juntos (no te quitas si serías el único con acceso transversal)
El rol TI aparece en la UI con badge gris (slate) e icono Cpu.
Archivos tocados
Backend:
prisma/schema.prisma— enum + tabla + relación inversa en Userprisma/seed.ts— backfillsrc/utils/platform-admin.ts(nuevo) — helperssrc/utils/global-admin.ts— shim de compatsrc/services/auth.service.ts— platformRoles en login + refreshsrc/controllers/platform-staff.controller.ts(nuevo)src/routes/platform-staff.routes.ts(nuevo)src/app.ts— registro de ruta
Shared:
src/types/auth.ts— PlatformRole + JWTPayload + UserInfosrc/constants/roles.ts— SUPERSET_ROLES + helpers extendidos
Frontend:
lib/api/platform-staff.ts(nuevo)lib/hooks/use-platform-staff.ts(nuevo)app/(dashboard)/admin/staff/page.tsx(nuevo)components/layouts/sidebar.tsx— item Staff- 8 callsites de
isGlobalAdminRfcactualizados con 3er parámetroplatformRoles
Lo que NO se reclasificó (deliberadamente pospuesto)
Los endpoints existentes (subscription.controller, tenants.controller, sat.controller, usuarios.controller, etc.) siguen usando isGlobalAdmin() / requireGlobalAdmin() sin cambios. Funcionan igual que antes porque la lógica interna migró a tabla. Siguiente iteración: reclasificar caso por caso:
| Endpoint | Debería requerir |
|---|---|
subscription.controller.ts:updatePlanPrice |
canEditPrices (admin + finance + TI) |
subscription.controller.ts:markAsPaid |
canEmitInvoicesManual (admin + finance + TI) |
subscription.controller.ts:getAllSubscriptions |
cualquier platform staff (read-only) |
tenants.controller.ts:create/update/delete |
canManageTenants (admin + sales + support + TI) |
sat.controller.ts:cronInfo/runCron |
hasPlatformRole('platform_admin') o TI (operacional) |
Esto se hará en un pase separado — no bloquea esta entrega, y diferirlo permite revisar cada caso con cuidado.
Verificación manual post-deploy
1. Re-logueate — ahora el JWT incluye platformRoles
2. Admin global (si corriste bootstrap:admin-global o heredaste el user):
- Ve sidebar con "Staff" + "Audit Log"
- Navega a /admin/staff — ve su propio user con badge Admin
- Busca otro user, asigna role TI → ahora ese user tendrá acceso como admin
- Intenta quitarte tu propio Admin → bloqueado si eres el último superset
3. User sin platform roles — no ve Staff ni Audit Log, ve "Acceso restringido" si entra directo a la URL
4. Audit log de la acción (grant/revoke) aparece con action = platform_role.granted/revoked
Pendientes para siguiente iteración
- Reclasificar endpoints existentes por
canX()granular (tabla arriba). - UI para quitar al usuario completo de staff (ahora solo se quitan roles individuales — si quitas todos, el user sigue existiendo como tenant user normal).
- Expiración de roles (ej: acceso temporal de soporte por 7 días) — requiere campo
expiresAten la tabla + cron de limpieza. - Agregar rol a user sin tenant activo — caso edge: staff de Horux 360 que no tiene tenant asignado (solo existe para propósitos administrativos). Hoy la UI requiere que el user exista como miembro de algún tenant.
- Renombrar funciones compat — cuando los endpoints se reclasifiquen,
isGlobalAdminpuede removerse completamente.