Skip to content

Audit Logs

✅ Implementado

El módulo AuditLogsModule proporciona trazabilidad completa de las operaciones relevantes de la plataforma. Registra quién hizo qué, cuándo, desde qué IP y con qué resultado, sin modificar la lógica de negocio de ningún controller.

Motivación (ADR-013): A medida que la plataforma incorpora roles admin con permisos amplios (SUPER_ADMIN, CONTENT_ADMIN, CORPORATE_ADMIN), se hace necesario un registro inmutable de las acciones de escritura para auditoría de seguridad, cumplimiento normativo y diagnóstico de incidencias.

Qué resuelve:

  • Trazabilidad de acciones de escritura (POST, PATCH, DELETE) en todos los módulos
  • Visibilidad sobre impersonaciones, escaladas de privilegio y accesos fallidos
  • Exportación de registros para auditorías externas (CSV con BOM UTF-8)
  • Panel de monitorización en tiempo real para SUPER_ADMIN

Archivos clave:

  • apps/api/src/modules/audit-logs/ — módulo NestJS completo
  • apps/web/src/pages/admin/AdminAuditLogsPage.tsx — panel frontend
  • apps/web/src/store/api/auditLogsApi.ts — slice RTK Query

sequenceDiagram
participant C as Cliente
participant CT as Controller
participant IN as AuditLogInterceptor
participant SV as AuditLogService
participant DB as PostgreSQL (AuditLog)
C->>CT: PATCH /api/admin/users/:id
CT->>IN: intercept() — lee metadata @AuditLog()
IN->>CT: next.handle() — ejecuta el handler
CT-->>IN: respuesta (resultado del handler)
IN->>SV: write({ actorId, action, ... }) [fire-and-forget]
SV->>DB: prisma.auditLog.create(...)
IN-->>C: respuesta original (sin bloquear)
Note over IN,SV: Si el handler lanza error,<br/>el interceptor escribe el log<br/>con success=false y severity=CRITICAL

Archivo: apps/api/src/modules/audit-logs/decorators/audit-log.decorator.ts

El decorador se aplica sobre el método del controller. El interceptor global lo lee mediante Reflector y escribe el log al finalizar la request.

import { AuditLog } from '../audit-logs/decorators/audit-log.decorator'
@Patch(':id')
@AuditLog({
action: 'PATH_UPDATED', // enum AuditAction — obligatorio
category: 'CONTENT', // enum AuditCategory — obligatorio
severity: 'INFO', // AuditSeverity — obligatorio
entityType: 'PATH', // AuditEntityType — opcional
entityIdParam: 'id', // nombre del @Param() que identifica la entidad
entityLabelParam: 'title', // campo del resultado para etiqueta legible
captureBody: true, // guarda req.body en `after` (sanitiza campos sensibles)
description: 'Actualizar path', // descripción libre opcional
})
async update(@Param('id') id: string, @Body() dto: UpdatePathDto) {
return this.pathsService.update(id, dto)
}
PropiedadTipoObligatorioDescripción
actionAuditActionAcción que identifica el evento
categoryAuditCategoryAgrupación temática del evento
severityAuditSeverityNivel de criticidad: INFO, WARNING, CRITICAL
entityTypeAuditEntityTypeNoTipo de entidad afectada
entityIdParamstringNoNombre del @Param() que contiene el ID de la entidad
entityLabelParamstringNoCampo del resultado de la response para etiqueta legible
captureBodybooleanNoSi true, guarda req.body sanitizado en el campo after
descriptionstringNoTexto libre descriptivo (no se persiste, solo metadatos)

Qué captura el interceptor automáticamente

Section titled “Qué captura el interceptor automáticamente”

Sin necesidad de configuración extra, el interceptor extrae de cada request:

CampoFuente
actorIdreq.user.sub (JWT)
actorRolereq.user.role (JWT)
actorEmailreq.user.email (JWT)
actorIpreq.ip o header x-forwarded-for
actorUserAgentheader user-agent
sessionIdreq.user.sessionId (JWT)
orgIdreq.user.organizationId (JWT, usuarios corporativos)
metadata.requestIdheader x-request-id

Agrupados por categoría temática en schema.prisma:

CategoríaEjemplos representativosCantidad
AUTHUSER_LOGIN, IMPERSONATION_STARTED, SESSION_REVOKED10
USER_MANAGEMENTUSER_CREATED, USER_ROLE_CHANGED, USER_SUSPENDED6
CONTENTPATH_CREATED, LESSON_UPDATED, QUIZ_DELETED19
ORGANIZATIONORG_CREATED, ORG_MEMBER_INVITED, ORG_SSO_CONFIGURED17
BILLINGPAYMENT_SUCCESS, SUBSCRIPTION_UPGRADED, PAYOUT_PERIOD_CLOSED9
AI_SYSTEMAI_PROVIDER_CREDENTIAL_ROTATED, AI_ROUTING_CONFIG_CHANGED10
SECURITYSECURITY_BRUTE_FORCE_DETECTED, SECURITY_PRIVILEGE_ESCALATION5
STUDENT_PROGRESSLESSON_COMPLETED, CERTIFICATE_ISSUED, BADGE_AWARDED9
SYSTEM_CONFIGSYSTEM_CONFIG_CHANGED, CREATOR_APPLICATION_APPROVED8
AUTH | USER_MANAGEMENT | CONTENT | ORGANIZATION | BILLING
AI_SYSTEM | SECURITY | STUDENT_PROGRESS | SYSTEM_CONFIG
ValorCuándo usarlo
INFOOperación rutinaria de escritura (default)
WARNINGAcción sensible que requiere revisión (ej. cambio de rol)
CRITICALEvento de seguridad o fallo de la operación (el interceptor lo fuerza en errores)
USER | ORGANIZATION | PATH | MODULE | LESSON | QUIZ | CERTIFICATE
AI_PROVIDER | AI_TASK | BADGE | CREATOR_APPLICATION | PAYMENT | SYSTEM_CONFIG

Incorrecto — el interceptor no se ejecutará:

// MAL: @Res() sin passthrough bloquea el interceptor
@Get('export')
@AuditLog({ action: 'USER_EXPORTED', category: 'USER_MANAGEMENT', severity: 'WARNING' })
async exportCsv(@Res() res: Response) {
res.json({ data: '...' }) // ← interceptor nunca se activa
}

Correcto:

// BIEN: passthrough:true + return valor
@Get('export')
@AuditLog({ action: 'USER_EXPORTED', category: 'USER_MANAGEMENT', severity: 'WARNING' })
async exportCsv(@Res({ passthrough: true }) res: Response) {
res.setHeader('Content-Type', 'text/csv')
return csvData // ← interceptor recibe la respuesta y escribe el log
}

  1. Abre apps/api/prisma/schema.prisma y añade el valor al enum AuditAction en el grupo temático correspondiente:
enum AuditAction {
// ... valores existentes ...
// CONTENT
GUIDE_PUBLISHED // ← nuevo valor
}
  1. Aplica el cambio a la base de datos (Neon no soporta shadow DB, usar db push):
Terminal window
cd apps/api
pnpm prisma db push
  1. Usa el nuevo valor en el decorator del controller:
@AuditLog({ action: 'GUIDE_PUBLISHED', category: 'CONTENT', severity: 'INFO' })

Archivo: apps/api/src/modules/audit-logs/interceptors/audit-log.interceptor.ts

AuditLogInterceptor es un NestInterceptor que actúa después de que el handler del controller ejecuta. Sigue el patrón tap/catchError de RxJS:

  • Éxito (tap): escribe el log con success: true y la severidad declarada en el decorador.
  • Error (catchError): escribe el log con success: false, severity: 'CRITICAL', y los campos errorCode y errorMessage del error HTTP. Relanza el error para que NestJS lo procese normalmente.

La escritura es fire-and-forget: un .catch() captura errores del write() y los loguea, sin bloquear la respuesta al cliente.

Cuando captureBody: true, el interceptor clona req.body y enmascara cualquier clave cuyo nombre contenga alguna de estas subcadenas (insensible a mayúsculas):

password | key | secret | token | apiKey | api_key | credential

El valor enmascarado es literalmente "[REDACTED]".

model AuditLog {
id String @id @default(cuid())
actorId String?
actorRole UserRole?
actorEmail String?
actorIp String?
actorUserAgent String?
action AuditAction
category AuditCategory
severity AuditSeverity @default(INFO)
entityType AuditEntityType?
entityId String?
entityLabel String?
before Json?
after Json? // req.body sanitizado si captureBody=true
metadata Json? // { requestId }
success Boolean @default(true)
errorCode String?
errorMessage String?
sessionId String?
requestId String?
orgId String?
createdAt DateTime @default(now())
}

Los índices cubren: actorId, action, category, severity, (entityType, entityId), orgId, createdAt, actorRole.


Todos los endpoints están bajo /api/admin/audit-logs y requieren JwtAuthGuard + RolesGuard con rol SUPER_ADMIN.

GET /api/admin/audit-logs SUPER_ADMIN Listado paginado con filtros
GET /api/admin/audit-logs/stats SUPER_ADMIN KPIs del día actual
GET /api/admin/audit-logs/export SUPER_ADMIN Exportar a CSV (máx. 10 000 filas)
GET /api/admin/audit-logs/entity/:entityType/:entityId SUPER_ADMIN Historial de una entidad concreta (últimos 50)
GET /api/admin/audit-logs/:id SUPER_ADMIN Detalle de un registro por ID

Parámetros de filtrado (GET /api/admin/audit-logs)

Section titled “Parámetros de filtrado (GET /api/admin/audit-logs)”
ParámetroTipoDescripción
pagenumberPágina (default: 1)
limitnumberRegistros por página (default: 25, máx: 100)
actorRolestringFiltrar por rol del actor
categorystringAuditCategory
severitystringAuditSeverity
actionstringAuditAction exacta
entityTypestringAuditEntityType
entityIdstringID de la entidad
actorIdstringID del usuario que realizó la acción
orgIdstringID de la organización
searchstringBúsqueda libre en actorEmail y entityLabel (insensible a mayúsculas)
fromISO dateFecha de inicio del rango
toISO dateFecha de fin del rango
successbooleantrue / false para filtrar por resultado
{
"totalToday": 142,
"criticalToday": 3,
"failedToday": 7,
"uniqueActorsToday": 12,
"impersonationsToday": 1,
"totalTrend": 18,
"criticalTrend": -25
}

totalTrend y criticalTrend son porcentajes de variación respecto al día anterior.

Export CSV (GET /api/admin/audit-logs/export)

Section titled “Export CSV (GET /api/admin/audit-logs/export)”

Acepta los mismos filtros que el listado. Devuelve hasta 10 000 filas con BOM UTF-8 para compatibilidad con Excel. Nombre de archivo: audit-logs-YYYY-MM-DD.csv.

Columnas: id, timestamp, action, category, severity, actorEmail, actorRole, actorIp, entityType, entityId, entityLabel, success, errorCode, errorMessage.


Ruta: /admin/audit-logs — accesible solo con rol SUPER_ADMIN
Archivo: apps/web/src/pages/admin/AdminAuditLogsPage.tsx
Slice RTK Query: apps/web/src/store/api/auditLogsApi.ts

  • KPI cards (5): eventos hoy, eventos críticos, actores únicos, acciones fallidas, impersonaciones — con trend respecto a ayer. Se refrescan automáticamente cada 60 s.
  • Quick filters: accesos directos a categorías y severidades frecuentes (Solo críticos, Seguridad, Usuarios, Contenido, etc.).
  • Barra de filtros: búsqueda libre, filtro por rol, categoría, severidad y rango de fechas.
  • Tabla paginada: columnas Timestamp, Acción·Entidad, Actor, Rol, Severidad, IP. Polling automático cada 30 s. Paginación con hasta 5 páginas visibles.
  • Drawer de detalle: panel lateral (480 px) que se abre al clicar una fila. Muestra resumen del evento, datos del actor, y bloques JSON de before/after/metadata. Permite copiar el ID o compartir un enlace directo.
  • Exportar CSV: botón que llama directamente al endpoint /export con los filtros activos, adjuntando el token JWT en el header Authorization.
import {
useGetAuditLogsQuery, // listado paginado con filtros
useGetAuditLogStatsQuery, // KPIs del día
useGetAuditLogDetailQuery, // detalle de un registro por ID
useGetEntityAuditLogsQuery, // historial de una entidad concreta
} from '@/store/api/auditLogsApi'

@Global()
@Module({
imports: [PrismaModule],
controllers: [AuditLogsController],
providers: [AuditLogService],
exports: [AuditLogService],
})
export class AuditLogsModule {}

Al estar marcado como @Global(), no es necesario importar AuditLogsModule en ningún otro módulo. El AuditLogService está disponible para inyección en cualquier parte de la aplicación.

Esto también significa que el AuditLogInterceptor puede inyectar AuditLogService sin imports adicionales. El interceptor se registra globalmente en AppModule.


El servicio expone anonymizeActor(userId) para cumplir con solicitudes de supresión de datos:

await this.auditLogService.anonymizeActor(userId)
// Resultado: actorId=null, actorEmail='[eliminado]', actorIp='[anonimizado]', actorUserAgent=null

Los registros de auditoría se conservan (no se borran) pero los datos personales del actor quedan eliminados. La trazabilidad de la acción permanece intacta.