# 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: ```typescript // 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 ```prisma 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`): ```typescript export async function hasPlatformRole(userId: string, role: PlatformRole): Promise { ... } export async function canManageTenants(userId: string): Promise { // platform_admin O platform_sales O platform_support } export async function canEditPrices(userId: string): Promise { // platform_admin O platform_finance } export async function canEmitInvoices(userId: string): Promise { // platform_admin O platform_finance } export async function isPlatformStaff(userId: string): Promise { // cualquier platform_* role } ``` Middleware nuevo en routes: ```typescript 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: ```sql 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 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.ts` — `PlatformRole` enum, helpers - `packages/shared/src/types/auth.ts` — `JWTPayload` 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:** ```sql 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.