Files
HoruxDespachosNuevo/docs/sessions/2026-05-04-cross-tenant-access-platform-staff.md
Horux Dev 46846200da feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva:
- Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva
- sat-parser.service.ts: extrae InformacionGlobal del XML
- sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05)
- metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas:
  reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h)
- Script recalc-metricas.ts para recalculo manual

Fallback datos fiscales tenant → contribuyente:
- contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant
  rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente
  tiene el mismo RFC que el tenant y sus campos estan vacios
- contribuyente.controller.ts y contribuyente-config.controller.ts:
  pasan req.user!.tenantId al servicio

Fix critico SAT sync:
- sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs
  (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global',
   causando fallo en 100% de inserciones de CFDI)
- determineChunkMonths: salta sondeo si existe job previo con requestIds
- MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes

Docs:
- docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
2026-05-22 15:52:10 +00:00

11 KiB

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

export async function isGlobalAdmin(tenantId: string, role: string, userId?: string): Promise<boolean> {
  // 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

// 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

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

async function requireGlobalAdmin(req: Request): Promise<void> {
  // 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

async function isGlobalAdmin(req: Request): Promise<boolean> {
  // 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

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

// 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

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

// 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

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

// FIX: Solo llama a useTenants() si es global admin
const { data: tenants } = useTenants({ enabled: isGlobalAdminRfc });

apps/web/app/(dashboard)/admin/usuarios/page.tsx

// 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

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.