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
updatedAtpero no quién ni por qué - FIEL re-subida → timestamp sí, autor no
platform_admincreó/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
- Performance: cada write = INSERT extra. Para 100 req/s ≈ 100 INSERT/s en
audit_log. Postgres lo aguanta fácilmente, pero worth monitoring. - PII en metadata: IP y user agent pueden considerarse PII. Revisar si hay que cifrar o limitar retention.
- Logging del propio audit: si el INSERT al
audit_logfalla, no bloquear la acción. El helper ya hace catch silencioso.
Archivos a tocar
apps/api/prisma/schema.prisma— modeloAuditLogapps/api/src/utils/audit.ts— helper nuevoapps/api/src/services/*.service.ts— llamadas aauditLogen acciones críticasapps/api/src/controllers/*.controller.ts— ídemapps/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 trasplatform_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 comouser.session_revoked.
Implementación ejecutada (2026-04-14)
Lo que se construyó
Schema:
- Tabla
audit_logcon 4 índices (userId+createdAt,tenantId+createdAt,action+createdAt,entityType+entityId). Aplicada víaprisma db pushcontra la BD local.
Helper:
apps/api/src/utils/audit.tscon:auditLog(params)— fire-and-forget básicoauditFromReq(req, action, extra)— extrae user/tenant del request y enriquece metadata conip+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 (
requireGlobalAdminvia controller) - Query params:
action(prefix match),tenantId,userId,from,to,page,limit(max 200) - Respuesta enriquecida con
user.email/nombreytenant.nombre/rfc(join en memoria, no en schema) - Archivos:
controllers/audit-log.controller.ts,routes/audit-log.routes.ts, registrada enapp.ts
Frontend:
lib/api/audit-log.ts,lib/hooks/use-audit-log.tsapp/(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(iconFileWarning) — 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
isGlobalAdminRfcy 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" → prefixsubscription.), más inputs libres para tenantId/userId/fechas. - Cada row tiene "Ver detalle" que expande un card con el JSON completo de
metadataformateado. - Paginación simple prev/next (no jump-to-page — scope mínimo MVP).
Pendientes para siguiente iteración
- 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.
- Instrumentar los 7 eventos pospuestos (lista arriba).
- Retention policy — tabla crece indefinidamente. Cron mensual que archiva > 2 años a S3 o simplemente borre (depende de compliance fiscal que aplica).
- Filtro por entityId — útil para "¿qué pasó con la suscripción X?". Backend ya soporta vía query param, solo falta input en UI.
- 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