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

285 lines
15 KiB
Markdown

# 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<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:
```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.