Audit Logs
¿Qué es?
Section titled “¿Qué es?”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 completoapps/web/src/pages/admin/AdminAuditLogsPage.tsx— panel frontendapps/web/src/store/api/auditLogsApi.ts— slice RTK Query
Diagrama de flujo
Section titled “Diagrama de flujo”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=CRITICALEl decorador @AuditLog()
Section titled “El decorador @AuditLog()”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)}Opciones de AuditLogConfig
Section titled “Opciones de AuditLogConfig”| Propiedad | Tipo | Obligatorio | Descripción |
|---|---|---|---|
action | AuditAction | Sí | Acción que identifica el evento |
category | AuditCategory | Sí | Agrupación temática del evento |
severity | AuditSeverity | Sí | Nivel de criticidad: INFO, WARNING, CRITICAL |
entityType | AuditEntityType | No | Tipo de entidad afectada |
entityIdParam | string | No | Nombre del @Param() que contiene el ID de la entidad |
entityLabelParam | string | No | Campo del resultado de la response para etiqueta legible |
captureBody | boolean | No | Si true, guarda req.body sanitizado en el campo after |
description | string | No | Texto 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:
| Campo | Fuente |
|---|---|
actorId | req.user.sub (JWT) |
actorRole | req.user.role (JWT) |
actorEmail | req.user.email (JWT) |
actorIp | req.ip o header x-forwarded-for |
actorUserAgent | header user-agent |
sessionId | req.user.sessionId (JWT) |
orgId | req.user.organizationId (JWT, usuarios corporativos) |
metadata.requestId | header x-request-id |
Enums disponibles
Section titled “Enums disponibles”AuditAction — 93 valores
Section titled “AuditAction — 93 valores”Agrupados por categoría temática en schema.prisma:
| Categoría | Ejemplos representativos | Cantidad |
|---|---|---|
| AUTH | USER_LOGIN, IMPERSONATION_STARTED, SESSION_REVOKED | 10 |
| USER_MANAGEMENT | USER_CREATED, USER_ROLE_CHANGED, USER_SUSPENDED | 6 |
| CONTENT | PATH_CREATED, LESSON_UPDATED, QUIZ_DELETED | 19 |
| ORGANIZATION | ORG_CREATED, ORG_MEMBER_INVITED, ORG_SSO_CONFIGURED | 17 |
| BILLING | PAYMENT_SUCCESS, SUBSCRIPTION_UPGRADED, PAYOUT_PERIOD_CLOSED | 9 |
| AI_SYSTEM | AI_PROVIDER_CREDENTIAL_ROTATED, AI_ROUTING_CONFIG_CHANGED | 10 |
| SECURITY | SECURITY_BRUTE_FORCE_DETECTED, SECURITY_PRIVILEGE_ESCALATION | 5 |
| STUDENT_PROGRESS | LESSON_COMPLETED, CERTIFICATE_ISSUED, BADGE_AWARDED | 9 |
| SYSTEM_CONFIG | SYSTEM_CONFIG_CHANGED, CREATOR_APPLICATION_APPROVED | 8 |
AuditCategory
Section titled “AuditCategory”AUTH | USER_MANAGEMENT | CONTENT | ORGANIZATION | BILLINGAI_SYSTEM | SECURITY | STUDENT_PROGRESS | SYSTEM_CONFIGAuditSeverity
Section titled “AuditSeverity”| Valor | Cuándo usarlo |
|---|---|
INFO | Operación rutinaria de escritura (default) |
WARNING | Acción sensible que requiere revisión (ej. cambio de rol) |
CRITICAL | Evento de seguridad o fallo de la operación (el interceptor lo fuerza en errores) |
AuditEntityType
Section titled “AuditEntityType”USER | ORGANIZATION | PATH | MODULE | LESSON | QUIZ | CERTIFICATEAI_PROVIDER | AI_TASK | BADGE | CREATOR_APPLICATION | PAYMENT | SYSTEM_CONFIGCompatibilidad con @Res()
Section titled “Compatibilidad con @Res()”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}Añadir una nueva AuditAction
Section titled “Añadir una nueva AuditAction”- Abre
apps/api/prisma/schema.prismay añade el valor al enumAuditActionen el grupo temático correspondiente:
enum AuditAction { // ... valores existentes ...
// CONTENT GUIDE_PUBLISHED // ← nuevo valor}- Aplica el cambio a la base de datos (Neon no soporta shadow DB, usar
db push):
cd apps/apipnpm prisma db push- Usa el nuevo valor en el decorator del controller:
@AuditLog({ action: 'GUIDE_PUBLISHED', category: 'CONTENT', severity: 'INFO' })El interceptor
Section titled “El interceptor”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 consuccess: truey la severidad declarada en el decorador. - Error (
catchError): escribe el log consuccess: false,severity: 'CRITICAL', y los camposerrorCodeyerrorMessagedel 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.
Sanitización de captureBody
Section titled “Sanitización de captureBody”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 | credentialEl valor enmascarado es literalmente "[REDACTED]".
Campos del modelo AuditLog
Section titled “Campos del modelo AuditLog”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.
API REST del admin
Section titled “API REST del admin”Todos los endpoints están bajo /api/admin/audit-logs y requieren JwtAuthGuard + RolesGuard con rol SUPER_ADMIN.
/api/admin/audit-logs SUPER_ADMIN Listado paginado con filtros /api/admin/audit-logs/stats SUPER_ADMIN KPIs del día actual /api/admin/audit-logs/export SUPER_ADMIN Exportar a CSV (máx. 10 000 filas) /api/admin/audit-logs/entity/:entityType/:entityId SUPER_ADMIN Historial de una entidad concreta (últimos 50) /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ámetro | Tipo | Descripción |
|---|---|---|
page | number | Página (default: 1) |
limit | number | Registros por página (default: 25, máx: 100) |
actorRole | string | Filtrar por rol del actor |
category | string | AuditCategory |
severity | string | AuditSeverity |
action | string | AuditAction exacta |
entityType | string | AuditEntityType |
entityId | string | ID de la entidad |
actorId | string | ID del usuario que realizó la acción |
orgId | string | ID de la organización |
search | string | Búsqueda libre en actorEmail y entityLabel (insensible a mayúsculas) |
from | ISO date | Fecha de inicio del rango |
to | ISO date | Fecha de fin del rango |
success | boolean | true / false para filtrar por resultado |
Respuesta GET /api/admin/audit-logs/stats
Section titled “Respuesta GET /api/admin/audit-logs/stats”{ "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.
Panel frontend
Section titled “Panel frontend”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
Funcionalidades del panel
Section titled “Funcionalidades del panel”- 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
/exportcon los filtros activos, adjuntando el token JWT en el headerAuthorization.
Hooks RTK Query disponibles
Section titled “Hooks RTK Query disponibles”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'AuditLogsModule es @Global()
Section titled “AuditLogsModule es @Global()”@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.
Anonimización RGPD
Section titled “Anonimización RGPD”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=nullLos 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.