Files
HoruxDespachosNuevo/docs/plans/2026-04-14-platform-admin-roles.md

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:

  1. 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).
  2. 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.
  3. Hardcode disperso. Cada vez que se agrega un endpoint admin, hay que recordar llamar isGlobalAdmin() o requireGlobalAdmin(). Fácil olvidar. Fácil de equivocarse en la condición (p.ej. la bug que encontramos con tenant-selector que disparaba /tenants para 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.tsplatform-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

  1. 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) incluir platformRoles en el claim al login, re-login forzado tras deploy; (b) resolver platformRoles en cada request desde BD (costo extra por request pero sin re-login).
  2. isGlobalAdminRfc usado en frontend. El frontend hoy lee user.tenantRfc del store — no puede consultar el nuevo padrón desde ahí sin un round-trip al API. Patrón: al login incluir platformRoles en la response y guardarlo en el store.
  3. Superposición con rol per-tenant. Un user puede ser platform_finance + owner de su tenant. Ambos roles aplican en contextos distintos — NO son excluyentes. El código debe checar cada uno por su lado.
  4. 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.prisma
  • apps/api/prisma/seed.ts (migración idempotente)
  • apps/api/src/utils/global-admin.ts → renombrar a platform-admin.ts
  • apps/api/src/controllers/tenants.controller.ts, sat.controller.ts, subscription.controller.ts, usuarios.controller.ts, facturacion.controller.ts — reclasificar permisos
  • apps/api/src/services/auth.service.ts — incluir platformRoles en response del login
  • packages/shared/src/constants/roles.tsPlatformRole enum, helpers
  • packages/shared/src/types/auth.tsJWTPayload con platformRoles?: PlatformRole[]
  • apps/web/stores/auth-store.ts — campo nuevo
  • apps/web/components/tenant-selector.tsx, sidebar*.tsx, admin/usuarios/page.tsx, clientes/page.tsx, configuracion/suscripcion/page.tsx — reemplazar isGlobalAdminRfc por hasPlatformRole(...)
  • Doc nuevo docs/plans/YYYY-MM-DD-platform-admin-roles-implementation.md documentando la ejecución

Implementación ejecutada (2026-04-14)

Lo que se construyó

Schema:

  • Enum PlatformRole con 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_roles con @unique([userId, role]), FK a User con onDelete: Cascade, campo createdBy para audit trail.

Helpers (apps/api/src/utils/platform-admin.ts):

  • hasPlatformRole(userId, role) — check específico, superset roles implican todo
  • hasAnyPlatformRole(userId, ...roles) — OR de varios
  • canManageTenants, canEditPrices, canEmitInvoicesManual, isPlatformStaff — atajos granulares
  • getPlatformRoles(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.ts ahora re-exporta isGlobalAdmin de platform-admin.ts — todos los ~20 callsites existentes (controllers, services) siguen funcionando sin cambios.
  • isGlobalAdminRfc() en shared acepta tercer parámetro opcional platformRoles. Los 8 callsites del frontend se actualizaron para pasar user?.platformRoles.

JWT + Login:

  • JWTPayload y UserInfo en shared incluyen platformRoles?: PlatformRole[].
  • auth.service.ts:login() y refreshTokens() pueblan via getPlatformRoles(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 user
  • GET /search?q=... — busca candidatos por email/nombre
  • POST /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 (isGlobalAdminRfc con platformRoles) + 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.granted y platform_role.revoked se instrumentan automáticamente vía auditFromReq.

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 User
  • prisma/seed.ts — backfill
  • src/utils/platform-admin.ts (nuevo) — helpers
  • src/utils/global-admin.ts — shim de compat
  • src/services/auth.service.ts — platformRoles en login + refresh
  • src/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 + UserInfo
  • src/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 isGlobalAdminRfc actualizados con 3er parámetro platformRoles

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

  1. Reclasificar endpoints existentes por canX() granular (tabla arriba).
  2. 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).
  3. Expiración de roles (ej: acceso temporal de soporte por 7 días) — requiere campo expiresAt en la tabla + cron de limpieza.
  4. 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.
  5. Renombrar funciones compat — cuando los endpoints se reclasifiquen, isGlobalAdmin puede removerse completamente.