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
268 lines
11 KiB
Markdown
268 lines
11 KiB
Markdown
# 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<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`
|
|
```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<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`
|
|
```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`
|
|
```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.
|