# Sesión: Fix de Acceso Cross-Tenant para Platform Staff (`platform_ti`) **Fecha:** 2026-05-04 **Participante:** Ivan (`ivan@horuxfin.com`) — Tenant role: `contador`, Platform role: `platform_ti` **User ID:** `9fa5f15e-5f17-4501-acde-f761f08ed533` **Tenant:** HORUX 360 (`c52c2f5d-b1ae-45c6-8cc8-b11c9611618a`) --- ## 1. Problema Reportado Ivan, con rol de plataforma `platform_ti`, no podía ver otros tenants (despachos) desde la sección "Despachos". Al navegar a `/despachos/contribuyentes`, recibía errores 403 y no veía el selector de tenant ni los datos de otros despachos. **Síntomas:** - Error 403 en `/api/tenants` - Error 403 en endpoints de stats de despachos (`/api/despacho-stats/*`) - No se mostraba el dropdown de selección de tenant en el frontend - Redirecciones incorrectas basadas únicamente en `role` (ignorando `platformRoles`) --- ## 2. Causas Raíz Identificadas ### 2.1 Backend — Autorización inconsistente | Problema | Ubicación | Detalle | |----------|-----------|---------| | `isGlobalAdmin()` no aceptaba `userId` | `apps/api/src/utils/platform-admin.ts` | Solo verificaba `role === 'owner'` + checks legacy. No revisaba `platformRoles`. | | `X-View-Tenant` bloqueado para `platform_ti` | `apps/api/src/middlewares/tenant.middleware.ts` | Solo permitía `platform_admin`, no `platform_ti`. | | Stats de despachos requerían `owner` exclusivamente | `apps/api/src/controllers/despacho-stats.controller.ts` | `getContribuyentesStats`, `getMisAsignados`, `getEquipoStats` usaban `ROLES_OWNER.has(role)`. | | `tenants.controller.ts` no pasaba `userId` | `apps/api/src/controllers/tenants.controller.ts` | `requireGlobalAdmin()` llamaba `isGlobalAdmin(tenantId, role)` sin `userId`. | | `usuarios.controller.ts` no pasaba `userId` | `apps/api/src/controllers/usuarios.controller.ts` | Mismo patrón que tenants. | ### 2.2 Frontend — Guards incompletos | Problema | Ubicación | Detalle | |----------|-----------|---------| | Redirección basada solo en `role` | `apps/web/app/(dashboard)/despachos/page.tsx` | No consideraba `platformRoles` para redirigir a `/despachos/contribuyentes`. | | `despacho-subnav.tsx` no incluía `platform_ti` | `apps/web/components/despachos/despacho-subnav.tsx` | `defaultDespachoPathForRole()` no manejaba platform roles. | | `contribuyentes/page.tsx` no permitía `platform_ti` | `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx` | `enabled` solo para `owner` / `cfo`. | | `mis-asignados/page.tsx` no permitía `platform_ti` | `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx` | Guard de roles excluía platform staff. | | `tenant-view-store.ts` no guardaba RFC | `apps/web/stores/tenant-view-store.ts` | `setViewingTenant` solo aceptaba `id` y `name`; faltaba `rfc` para headers. | | Llamadas sin guard a `/api/tenants` | `apps/web/app/(dashboard)/clientes/page.tsx` | `useTenants()` se ejecutaba sin verificar `isGlobalAdminRfc`. | | Llamadas sin guard en admin/usuarios | `apps/web/app/(dashboard)/admin/usuarios/page.tsx` | `useQuery` para `/api/tenants` sin condición de habilitación. | --- ## 3. Cambios Implementados ### 3.1 Backend #### `apps/api/src/utils/platform-admin.ts` ```ts export async function isGlobalAdmin(tenantId: string, role: string, userId?: string): Promise { // NUEVO: Bypass para platform staff if (userId && await hasAnyPlatformRole(userId, ...SUPERSET_ROLES)) { return true; } if (role !== 'owner') return false; // ... legacy checks } ``` #### `apps/api/src/middlewares/tenant.middleware.ts` ```ts // Admin impersonation via X-View-Tenant header const viewTenantHeader = req.headers['x-view-tenant'] as string; if (viewTenantHeader) { // FIX: Ahora acepta platform_admin y platform_ti const isPlatformStaff = await hasAnyPlatformRole(req.user.userId, 'platform_admin', 'platform_ti'); const globalAdmin = !isPlatformStaff && await isGlobalAdmin(req.user.tenantId, req.user.role); if (!isPlatformStaff && !globalAdmin) { return res.status(403).json({ message: 'No autorizado para ver otros tenants' }); } // ... resolve viewed tenant pool } ``` #### `apps/api/src/controllers/despacho-stats.controller.ts` ```ts function isPlatformStaff(user: any): boolean { return (user?.platformRoles || []).some((r: string) => ['platform_admin', 'platform_ti'].includes(r)); } // Aplicado a: // - getContribuyentesStats // - getMisAsignados // - getEquipoStats ``` #### `apps/api/src/controllers/tenants.controller.ts` ```ts async function requireGlobalAdmin(req: Request): Promise { // FIX: Ahora pasa req.user!.userId if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) { throw new AppError(403, 'Solo el administrador global puede gestionar clientes'); } } export async function getAllTenants(req: Request, res: Response, next: NextFunction) { // FIX FINAL: Devuelve 200 [] en lugar de 403 para no generar ruido en consola const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); if (!isAdmin) { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); return res.json([]); } // ... resto } export async function getTenant(req: Request, res: Response, next: NextFunction) { // FIX FINAL: Devuelve 404 en lugar de 403 const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); if (!isAdmin) { return res.status(404).json({ message: 'Cliente no encontrado' }); } // ... resto } ``` #### `apps/api/src/controllers/usuarios.controller.ts` ```ts async function isGlobalAdmin(req: Request): Promise { // FIX: Ahora pasa req.user!.userId return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); } ``` ### 3.2 Frontend #### `apps/web/components/despachos/despacho-subnav.tsx` ```ts const ITEMS: NavItem[] = [ { href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner','cfo','contador','visor','supervisor','auxiliar'] }, { href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck, roles: ['owner','cfo','supervisor','auxiliar','contador','visor'] }, { href: '/despachos/equipo', label: 'Equipo', icon: Users, roles: ['owner','cfo','supervisor'] }, ]; export function defaultDespachoPathForRole(role: string, platformRoles?: string[]): string { const isStaff = platformRoles?.some(r => ['platform_admin','platform_ti'].includes(r)); if (isStaff || role === 'owner' || role === 'cfo') return '/despachos/contribuyentes'; // ... } ``` #### `apps/web/app/(dashboard)/despachos/page.tsx` ```ts // FIX: Redirige platform_ti a contribuyentes if (platformRoles?.some(r => ['platform_admin', 'platform_ti'].includes(r))) { redirect('/despachos/contribuyentes'); } ``` #### `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx` ```ts const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']); const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false; const enabled = role === 'owner' || role === 'cfo' || isPlatformStaff; // Dropdown de selección de tenant (solo para platform staff) const { data: despachos } = useQuery({ queryKey: ['admin-despachos'], queryFn: fetchAdminDespachos, enabled: isPlatformStaff }); // onValueChange llama a setViewingTenant(despachoId, despachoName, despachoRfc) ``` #### `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx` ```ts // FIX: Ahora permite contador, visor, y platform staff const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']); const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false; const enabled = ['owner','cfo','supervisor','auxiliar','contador','visor'].includes(role || '') || isPlatformStaff; ``` #### `apps/web/stores/tenant-view-store.ts` ```ts interface TenantViewState { viewingTenantId: string | null; viewingTenantName: string | null; viewingTenantRfc: string | null; // NUEVO setViewingTenant: (id: string | null, name: string | null, rfc?: string | null) => void; clearViewingTenant: () => void; } ``` #### `apps/web/app/(dashboard)/clientes/page.tsx` ```ts // FIX: Solo llama a useTenants() si es global admin const { data: tenants } = useTenants({ enabled: isGlobalAdminRfc }); ``` #### `apps/web/app/(dashboard)/admin/usuarios/page.tsx` ```ts // FIX: Condicional en query para evitar 403 const { data: tenants } = useQuery({ queryKey: ['tenants'], queryFn: getTenants, enabled: isGlobalAdminRfc, // NUEVO }); ``` --- ## 4. Decisiones Arquitectónicas ### 4.1 Platform Roles como Superset Los roles `platform_admin` y `platform_ti` se tratan como un **superset** que bypassa casi todas las verificaciones de tenant-level authorization. Esto es intencional: el staff de plataforma necesita operar cross-tenant sin fricción. ### 4.2 Soft Fail para `GET /api/tenants` En lugar de devolver `403` a usuarios no autorizados en `GET /api/tenants`, se devuelve `200 []`. Esto elimina ruido en la consola del navegador cuando componentes React hacen polling o renderizan hooks incondicionalmente. Las operaciones de escritura (`POST`, `PUT`, `DELETE`) siguen devolviendo `403`. ### 4.3 `X-View-Tenant` Header El mecanismo de impersonación de tenant usa: 1. Frontend: `tenant-view-store` guarda `viewingTenantId` en `localStorage` (key: `horux-tenant-view`) 2. Frontend: `apiClient` lee el store e inyecta header `X-View-Tenant` 3. Backend: `tenantMiddleware` resuelve la DB pool del tenant visto ### 4.4 Cache Headers Se agregaron headers `Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate` a las respuestas de tenants para evitar que proxies/CDN cacheen datos cross-tenant. --- ## 5. Deploy ```bash cd /root/HoruxDespachosNuevo pnpm build --filter=@horux/web pm2 reload horux-api pm2 reload horux-web ``` **Estado:** ✅ Exitoso. Ivan confirmó que puede ver todos los tenants. --- ## 6. Archivos Modificados ### Backend - `apps/api/src/middlewares/tenant.middleware.ts` - `apps/api/src/utils/platform-admin.ts` - `apps/api/src/controllers/despacho-stats.controller.ts` - `apps/api/src/controllers/tenants.controller.ts` - `apps/api/src/controllers/usuarios.controller.ts` ### Frontend - `apps/web/components/despachos/despacho-subnav.tsx` - `apps/web/app/(dashboard)/despachos/page.tsx` - `apps/web/app/(dashboard)/despachos/contribuyentes/page.tsx` - `apps/web/app/(dashboard)/despachos/mis-asignados/page.tsx` - `apps/web/stores/tenant-view-store.ts` - `apps/web/app/(dashboard)/clientes/page.tsx` - `apps/web/app/(dashboard)/admin/usuarios/page.tsx` - `apps/web/hooks/use-tenants.ts` --- ## 7. Notas para Futuras Sesiones - Si se agrega un nuevo endpoint cross-tenant, siempre verificar que `isGlobalAdmin()` reciba `userId` para que los platform roles funcionen. - Si se agrega una nueva página en `/despachos/*`, usar `isPlatformStaff` o incluir platform roles en los guards. - El helper `isGlobalAdminRfc` en `@horux/shared` ya maneja `platformRoles`, pero los controllers backend deben usar la versión con `userId`. - Considerar centralizar la lógica de `isPlatformStaff` en un helper compartido backend/frontend para evitar duplicación.