Files
HoruxDespachosNuevo/docs/plans/2026-04-14-audit-log.md

238 lines
12 KiB
Markdown

# Audit log de acciones críticas
**Estado:****IMPLEMENTADO** (2026-04-14) — MVP operativo. 10 eventos instrumentados, endpoint + UI para admin global. La sección final "Implementación ejecutada" resume qué quedó vs qué se postergó.
## Problema
Hoy no hay registro de quién hizo qué. Acciones con implicaciones fiscales, financieras o de seguridad ocurren sin dejar rastro auditable:
- Admin global editó precios → ¿cuándo? ¿de $X a $Y? ¿quién?
- Se emitió factura manual → ¿por qué payment? ¿quién la emitió?
- Cliente canceló/reactivó suscripción → sí tenemos `updatedAt` pero no quién ni por qué
- FIEL re-subida → timestamp sí, autor no
- `platform_admin` creó/borró tenant → sin rastro
- Roles de plataforma asignados/removidos → sin rastro
El SAT en auditoría puede pedir registros de quién emitió facturas y cuándo. En disputa con cliente ("yo nunca cancelé"), no hay forma de defender.
## Propuesta
Tabla genérica + helper simple. Se instrumenta en los ~15 endpoints críticos.
### Schema
```prisma
model AuditLog {
id String @id @default(uuid())
userId String? @map("user_id") // Quién (null = sistema/cron)
tenantId String? @map("tenant_id") // Sobre qué tenant (si aplica)
action String // Evento: "price.updated", "subscription.cancelled", etc.
entityType String? @map("entity_type") // "Subscription", "Tenant", "PlanPrice", ...
entityId String? @map("entity_id") // ID del recurso afectado
metadata Json? // Antes/después, contexto, IP, UA
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, createdAt])
@@index([tenantId, createdAt])
@@index([action, createdAt])
@@index([entityType, entityId])
@@map("audit_log")
}
```
Nullable `userId` cubre eventos del sistema (cron jobs, webhooks automáticos). Nullable `tenantId` cubre acciones de admin global sin tenant específico.
### Helper
```typescript
// apps/api/src/utils/audit.ts
export async function auditLog(params: {
userId?: string;
tenantId?: string;
action: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, any>;
}): Promise<void> {
// Fire-and-forget: auditar NUNCA debe romper la acción principal.
try {
await prisma.auditLog.create({ data: { ...params } });
} catch (error) {
console.error('[Audit] Falló registrar evento:', error);
}
}
// Helper para controllers — extrae user+tenant del request
export async function auditFromReq(
req: Request,
action: string,
extra?: Partial<Parameters<typeof auditLog>[0]>,
) {
await auditLog({
userId: req.user?.userId,
tenantId: req.user?.tenantId,
action,
metadata: {
ip: req.ip,
userAgent: req.get('user-agent'),
...extra?.metadata,
},
...extra,
});
}
```
### Eventos a instrumentar (MVP)
| Action | Dónde | Metadata mínima |
|--------|-------|-----------------|
| `user.login` | `auth.service.ts:login` | `success: boolean`, email, IP |
| `user.logout` | `auth.service.ts:logout` | — |
| `user.password_changed` | `auth.service.ts` | — |
| `tenant.created` | `tenants.service.ts:createTenant` | nombre, rfc, plan |
| `tenant.deleted` | `tenants.service.ts` (si existe) | — |
| `subscription.created` | `subscription.service.ts:subscribe` | plan, frequency, amount |
| `subscription.cancelled` | `subscription.service.ts:cancelSubscription` | reason (si se pide) |
| `subscription.reactivated` | `subscription.service.ts:reactivateSubscription` | — |
| `subscription.plan_changed` | `subscription.service.ts:scheduleChange`/`applyApprovedUpgrade` | from/to plan, from/to frequency |
| `trial.started` | `startTrial` | plan, rfc |
| `price.updated` | `subscription.controller.ts:updatePlanPrice` | plan, frequency, from/to amount |
| `invoice.emitted_auto` | `invoicing.service.ts:emitInvoiceIfApplicable` | paymentId, facturapiInvoiceId, amount |
| `invoice.emitted_manual` | `facturacion.controller.ts:emitir` | cliente RFC, conceptos, folio |
| `invoice.cancelled` | facturapi cancellation | uuid |
| `fiel.uploaded` | `fiel.service.ts` | rfc, vigencia |
| `fiel.deleted` | | — |
| `payment.recorded` | webhook | source: `mercadopago`, amount, status |
| `payment.marked_paid_manually` | `subscription.controller.ts:markAsPaid` | amount |
| `platform_role.granted` | (futuro, ver plan admin roles) | role |
| `platform_role.revoked` | (futuro) | role |
### Lo que NO logear
- Lecturas normales (GET /cfdi, dashboard, etc.) — demasiado volumen
- Operaciones de cron silenciosas que ya logean en consola
- Errores (esos van a Sentry, no a audit)
## Frontend — visualización
Página `/admin/audit-log` (visible solo para `platform_admin` cuando exista):
- Filtros: por user, tenant, action, fecha
- Export CSV (útil en auditoría del SAT)
- Paginación
## Retention
Inicialmente guardar indefinidamente. Si el volumen crece, política:
- Cron mensual que archiva a S3 (Parquet o JSON comprimido) eventos > 2 años
- Tabla caliente con últimos 2 años solo
## Alcance
| Tarea | Estimación |
|-------|-----------|
| Schema + migración | 30 min |
| Helper `auditLog`/`auditFromReq` | 1 h |
| Instrumentar 15-20 callsites | 2-3 h |
| UI admin `/admin/audit-log` | 1 día (tabla + filtros + export) |
| Tests | 2-3 h |
| **Total** | **1-2 días** |
## Riesgos
1. **Performance:** cada write = INSERT extra. Para 100 req/s ≈ 100 INSERT/s en `audit_log`. Postgres lo aguanta fácilmente, pero worth monitoring.
2. **PII en metadata:** IP y user agent pueden considerarse PII. Revisar si hay que cifrar o limitar retention.
3. **Logging del propio audit:** si el INSERT al `audit_log` falla, no bloquear la acción. El helper ya hace catch silencioso.
## Archivos a tocar
- `apps/api/prisma/schema.prisma` — modelo `AuditLog`
- `apps/api/src/utils/audit.ts` — helper nuevo
- `apps/api/src/services/*.service.ts` — llamadas a `auditLog` en acciones críticas
- `apps/api/src/controllers/*.controller.ts` — ídem
- `apps/web/app/(dashboard)/admin/audit-log/page.tsx` — UI (cuando se implemente platform roles)
## Relación con otros planes
- **`2026-04-14-platform-admin-roles.md`:** la UI de audit-log vive gated tras `platform_admin`. El schema puede coexistir sin ese rol (acciones del admin global hoy se audita igual).
- **`2026-04-14-jwt-revocation.md`:** la revocación de JWT puede auditarse como `user.session_revoked`.
---
## Implementación ejecutada (2026-04-14)
### Lo que se construyó
**Schema:**
- Tabla `audit_log` con 4 índices (`userId+createdAt`, `tenantId+createdAt`, `action+createdAt`, `entityType+entityId`). Aplicada vía `prisma db push` contra la BD local.
**Helper:**
- `apps/api/src/utils/audit.ts` con:
- `auditLog(params)` — fire-and-forget básico
- `auditFromReq(req, action, extra)` — extrae user/tenant del request y enriquece metadata con `ip` + `userAgent`
**Eventos instrumentados (10):**
| Action | Dónde quedó | Metadata guardada |
|--------|-------------|-------------------|
| `user.login` | `auth.service.ts:login` | email, tenantRfc |
| `user.logout` | `auth.service.ts:logout` | — |
| `trial.started` | `subscription.service.ts:startTrial` | plan, frequency, rfc, trialEndsAt |
| `subscription.created` | `subscription.service.ts:subscribe` | plan, frequency, amount |
| `subscription.cancelled` | `subscription.service.ts:cancelSubscription` | plan, currentPeriodEnd |
| `subscription.reactivated` | `subscription.service.ts:reactivateSubscription` | plan, frequency, nextChargeAt |
| `subscription.plan_changed` (scheduled) | `subscription.service.ts:scheduleChange` | kind:'scheduled', fromPlan/toPlan, fromFrequency/toFrequency, effectiveAt |
| `subscription.plan_changed` (upgrade) | `subscription.service.ts:applyApprovedUpgrade` | kind:'upgrade_immediate', fromPlan/toPlan, frequency, newAmount |
| `price.updated` | `subscription.controller.ts:updatePlanPrice` | plan, frequency, fromAmount, toAmount |
| `invoice.emitted_auto` | `invoicing.service.ts:emitInvoiceIfApplicable` | facturapiInvoiceId, amount, plan, frequency |
| `payment.marked_paid_manually` | `subscription.service.ts:markAsPaidManually` | amount, subscriptionId |
**Endpoint:** `GET /api/audit-log`
- Admin global only (`requireGlobalAdmin` via controller)
- Query params: `action` (prefix match), `tenantId`, `userId`, `from`, `to`, `page`, `limit` (max 200)
- Respuesta enriquecida con `user.email/nombre` y `tenant.nombre/rfc` (join en memoria, no en schema)
- Archivos: `controllers/audit-log.controller.ts`, `routes/audit-log.routes.ts`, registrada en `app.ts`
**Frontend:**
- `lib/api/audit-log.ts`, `lib/hooks/use-audit-log.ts`
- `app/(dashboard)/admin/audit-log/page.tsx` — tabla con filtros, badges por tipo de acción, expandible para ver JSON metadata, paginación prev/next
- Sidebar: nuevo item "Audit Log" en `adminNavigation` (icon `FileWarning`) — solo visible para admin global
### Eventos que NO se instrumentaron (deliberadamente pospuestos)
| Action | Razón |
|--------|-------|
| `user.password_changed` | No existe endpoint de cambio de password en el repo actual. Se agrega al implementar el plan de `jwt-revocation`. |
| `tenant.created` / `tenant.deleted` | `createTenant` lo usa el admin global para provisionar; decidí priorizar los eventos con mayor valor auditable primero. Quedó para un siguiente pase de instrumentación. |
| `invoice.emitted_manual` | Requiere tocar `facturacion.controller.ts` que es área sensible (Facturapi); se pospuso para evitar tocar múltiples concerns en el mismo commit. |
| `invoice.cancelled` | Flujo de cancelación en Facturapi no está completamente implementado en el MVP del sistema de facturación; se instrumenta cuando se cierre ese loop. |
| `fiel.uploaded` / `fiel.deleted` | Pendiente — va en el próximo pase. |
| `payment.recorded` | **Decisión:** NO logear. Cada webhook de MP dispararía uno. Ruido sin valor auditable nuevo — el payment ya queda en tabla `payments`. Solo `payment.marked_paid_manually` importa auditar (intervención humana). |
### UI admin
La página `/admin/audit-log`:
- Gate de acceso doble: (a) frontend checa `isGlobalAdminRfc` y si no es admin global muestra card "Acceso restringido"; (b) backend devuelve 403 si el request no viene de admin global (defense in depth).
- Filtros con `<Select>` agrupado por familia ("Suscripciones" → prefix `subscription.`), más inputs libres para tenantId/userId/fechas.
- Cada row tiene "Ver detalle" que expande un card con el JSON completo de `metadata` formateado.
- Paginación simple prev/next (no jump-to-page — scope mínimo MVP).
### Pendientes para siguiente iteración
1. **CSV export** — el endpoint soporta paginación hasta 200 rows por request; para exports grandes habría que implementar streaming. Útil en auditoría SAT.
2. **Instrumentar los 7 eventos pospuestos** (lista arriba).
3. **Retention policy** — tabla crece indefinidamente. Cron mensual que archiva > 2 años a S3 o simplemente borre (depende de compliance fiscal que aplica).
4. **Filtro por entityId** — útil para "¿qué pasó con la suscripción X?". Backend ya soporta vía query param, solo falta input en UI.
5. **Vista individual por user** — "todas las acciones de este contador en el último mes".
### Verificación manual post-deploy
```
1. Logueate como admin@demo.com → aparece row user.login
2. Ve a /configuracion/suscripcion y cancela → aparece subscription.cancelled
3. Reactiva (si aplica) → aparece subscription.reactivated
4. Como admin global, edita un precio → aparece price.updated con from/to
5. Ve a /admin/audit-log → aparece sidebar solo si eres admin global
6. Intenta navegar a /admin/audit-log como admin@demo.com → ve "Acceso restringido"
7. Filtra por "Usuarios" → ve solo login/logout
```