Files
HoruxDespachos/docs/plans/2026-04-14-audit-log.md
2026-04-27 22:09:36 -06:00

12 KiB

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

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

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