285 lines
15 KiB
Markdown
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.
|