238 lines
12 KiB
Markdown
238 lines
12 KiB
Markdown
# 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<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
|
|
```
|