# 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; }): Promise { // 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[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 `