0009. Captura centralizada de auditoría en el backend (FastAPI)¶
Fecha: 2026-09-01 Última actualización: 2026-09-10 Estado: Propuesto Módulo(s) afectado(s): todos — transversal (FastAPI middleware, servicios, persistencia)
Contexto¶
El ADR 0008 definió el modelo de auditoría (audit_log, cfg_menu,
cfg_menu_acciones) y dónde se persiste (una tabla por producto). El ADR 0013
definió el modelo de navegación (cat_aplicaciones, cfg_modulos, cfg_menu,
cfg_menu_acciones). Falta definir cómo se captura el movimiento en el
backend de forma centralizada y con garantías:
- Que no dependa de que cada desarrollador recuerde insertar uno a uno.
- Que no rompa el patrón Router → Service → Repository (ADR 0001).
- Que no bloquee la transacción de negocio.
- Que cubra el 100% de los endpoints sin código manual.
- Que resuelva automáticamente el módulo, la página y la acción.
Problema del enfoque por decorador: un decorador @audit(menu_id=...,
accion_id=...) en cada endpoint obliga al desarrollador a conocer los códigos
del catálogo, escribirlo en cada ruta, y es propenso a olvidos. Si el endpoint
no lleva el decorador, no se registra nada → huecos de auditoría.
Decisión¶
La auditoría se captura desde un middleware HTTP global que intercepta todas las peticiones y registra automáticamente: método, endpoint, actor, empresa, módulo, página y acción. El desarrollador no escribe código de auditoría en el 90% de los casos (CRUD estándar). Solo usa un decorador opcional cuando necesita enriquecer el registro (acción especial, lectura sensible, datos de negocio).
Flujo de captura¶
Request HTTP → MiddlewareAudit (global, ANTES del Router)
│
├─ 1. CAPTURA AUTOMÁTICA (sin código del desarrollador):
│ • metodo_http (del Request: GET, POST, PUT, DELETE, PATCH)
│ • endpoint (del Request: /api/v1/inventario/ingresos)
│ • actor_user_id (del JWT: sub)
│ • actor_username (del JWT: username)
│ • actor_nombre (del JWT: nombre)
│ • actor_email (del JWT: email)
│ • empresa_id (del JWT: empresa_id)
│ • fecha (now())
│ • trace_id (del header X-Request-ID o generado)
│
├─ 2. RESOLUCIÓN DEL MÓDULO (mapping por servicio):
│ • Mapping cargado al startup desde MODULE_MAP
│ • /api/v1/facturas → modulo_codigo="contabilidad"
│ • /api/v1/pedidos → modulo_codigo="pedidos"
│ • Resultado: modulo_id, modulo_codigo, modulo_nombre
│
├─ 3. RESOLUCIÓN DEL CATÁLOGO (caché en memoria):
│ • Al startup carga cfg_menu + cfg_menu_acciones de core_sigfa
│ • Mapea (method, endpoint) → menu_id, accion_id, menu_codigo, etc.
│ • POST /api/v1/facturas → accion_codigo="crear"
│ • Refresh periódico cada 5 minutos
│
├─ 4. DECORADOR OPCIONAL (solo para enriquecer):
│ • @audit(accion_codigo="aprobar") ← override de POST→crear
│ • @audit(referencia_tipo="pedido") ← tipo de entidad
│ • @audit(sensitive=True) ← lectura sensible
│ • @audit(detalle="aprobó por $500") ← contexto de negocio
│
├─ 5. EJECUTA EL REQUEST (Router → Service → Repository)
│
└─ 6. AuditService.grabar():
• construye el registro audit_log completo
• encola evento (outbox, ADR 0003) — no bloquea la respuesta
• un consumidor persiste en audit_log de la BD del producto
El middleware (captura automática)¶
El middleware se registra una vez en main.py de cada servicio y captura
todas las peticiones HTTP entrantes. No conoce lógica de negocio; solo extrae
datos del request, del JWT y del catálogo cacheado.
# svc-contable/app/main.py
from fastapi import FastAPI
from app.audit.middleware import AuditMiddleware
app = FastAPI()
# Se registra ANTES de los routers
app.add_middleware(AuditMiddleware)
# svc-contable/app/audit/middleware.py
import uuid
from datetime import datetime, timezone
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from app.audit.module_map import MODULE_MAP
from app.audit.catalog_cache import CatalogCache
from app.audit.service import AuditService
class AuditMiddleware(BaseHTTPMiddleware):
def __init__(self, app, catalog_cache: CatalogCache | None = None):
super().__init__(app)
self.module_map = MODULE_MAP
self.catalog = catalog_cache or CatalogCache()
async def dispatch(self, request: Request, call_next) -> Response:
# ── 1. Datos automáticos del request ──
audit_data = {
"metodo_http": request.method,
"endpoint": str(request.url.path),
"fecha": datetime.now(timezone.utc),
"trace_id": self._extract_trace_id(request),
}
# ── 2. Datos del JWT (ya decodificado en el middleware de auth) ──
token = getattr(request.state, "token_payload", None)
if token:
audit_data.update({
"actor_user_id": uuid.UUID(token["sub"]),
"actor_username": token.get("username"),
"actor_nombre": token.get("nombre"),
"actor_email": token.get("email"),
"empresa_id": token.get("empresa_id"),
})
# ── 3. Resolución del módulo (mapping del servicio) ──
modulo = self._resolve_modulo(request.url.path)
if modulo:
audit_data.update({
"modulo_id": modulo["id"],
"modulo_codigo": modulo["codigo"],
"modulo_nombre": modulo["nombre"],
})
# ── 4. Resolución del catálogo (menú y acción) ──
catalog_entry = self.catalog.resolve(request.method, request.url.path)
if catalog_entry:
audit_data.update(catalog_entry)
# ── 5. Ejecuta el request ──
response = await call_next(request)
# ── 6. Graba auditoría (async, no bloquea) ──
audit_data["status_code"] = response.status_code
await AuditService.grabar(audit_data)
return response
def _resolve_modulo(self, path: str) -> dict | None:
"""Match por prefijo más largo: /api/v1/facturas/123 → /api/v1/facturas"""
sorted_prefixes = sorted(self.module_map.keys(), key=len, reverse=True)
for prefix in sorted_prefixes:
if path.startswith(prefix):
return self.module_map[prefix]
return None
def _extract_trace_id(self, request: Request) -> uuid.UUID | None:
header = request.headers.get("X-Request-ID")
if header:
try:
return uuid.UUID(header)
except ValueError:
pass
return uuid.uuid4()
Mapping por servicio (MODULE_MAP)¶
Cada microservicio declara una vez qué módulo(s) maneja. Este mapping
conecta los prefijos de los endpoints con los códigos de cfg_modulos (el
mismo codigo del seed definido en ADR 0013 de svc-identidad).
¿Por qué un mapping y no auto-detectar del path? Porque:
- El path del API (
/api/v1/facturas) no siempre coincide con el código del módulo encfg_modulos(contabilidad). - Un servicio puede manejar múltiples módulos (
svc-rrhhmanejarrhh). - Es explícito: si agregás un endpoint nuevo, el mapping te obliga a declararlo — sin olvidos.
# svc-contable/app/audit/module_map.py
"""
Mapping de endpoints → módulos para svc-contable.
Claves: prefijo del path (lo que viene después de /api/v1)
Valores: {id, codigo, nombre} del módulo en cfg_modulos
El id se carga al startup desde core_sigfa (caché en memoria).
El código es el mismo del seed (ADR 0013 de svc-identidad).
"""
MODULE_MAP = {
"/api/v1/facturas": {
"id": None, # se carga al startup desde core_sigfa
"codigo": "contabilidad",
"nombre": "Contabilidad",
},
"/api/v1/cuentas_por_cobrar": {
"id": None,
"codigo": "contabilidad",
"nombre": "Contabilidad",
},
"/api/v1/caja": {
"id": None,
"codigo": "contabilidad",
"nombre": "Contabilidad",
},
}
# svc-operaciones/app/audit/module_map.py
MODULE_MAP = {
"/api/v1/inventario": {
"id": None,
"codigo": "inventario",
"nombre": "Inventario",
},
"/api/v1/produccion": {
"id": None,
"codigo": "produccion",
"nombre": "Producción",
},
"/api/v1/calidad": {
"id": None,
"codigo": "calidad",
"nombre": "Calidad",
},
}
# svc-pedidos/app/audit/module_map.py
MODULE_MAP = {
"/api/v1/pedidos": {
"id": None,
"codigo": "pedidos",
"nombre": "Pedidos",
},
"/api/v1/cotizaciones": {
"id": None,
"codigo": "ventas",
"nombre": "Ventas",
},
}
# svc-rrhh/app/audit/module_map.py
MODULE_MAP = {
"/api/v1/nomina": {
"id": None,
"codigo": "rrhh",
"nombre": "RRHH",
},
"/api/v1/marcaciones": {
"id": None,
"codigo": "rrhh",
"nombre": "RRHH",
},
"/api/v1/asistencia": {
"id": None,
"codigo": "rrhh",
"nombre": "RRHH",
},
"/api/v1/faena_pesquera": {
"id": None,
"codigo": "rrhh",
"nombre": "RRHH",
},
}
# svc-clientes/app/audit/module_map.py
MODULE_MAP = {
"/api/v1/clientes": {
"id": None,
"codigo": "clientes",
"nombre": "Clientes",
},
"/api/v1/contactos": {
"id": None,
"codigo": "clientes",
"nombre": "Clientes",
},
}
# svc-fuerza-ventas/app/audit/module_map.py
MODULE_MAP = {
"/api/v1/promotores": {
"id": None,
"codigo": "promotores",
"nombre": "Promotores",
},
"/api/v1/presupuesto_cliente": {
"id": None,
"codigo": "ventas",
"nombre": "Ventas",
},
}
Resolución del catálogo (CatalogCache)¶
El CatalogCache carga al startup las tablas cfg_menu y
cfg_menu_acciones de core_sigfa y construye un mapping in-memory que
resuelve (method, endpoint) → datos de auditoría.
# svc-contable/app/audit/catalog_cache.py
import uuid
from datetime import datetime, timezone, timedelta
from sqlalchemy import text
from sqlalchemy.orm import Session
class CatalogCache:
"""
Caché en memoria del catálogo de navegación (cfg_menu + cfg_menu_acciones).
Se carga al startup desde core_sigfa y se refresca cada 5 minutos.
Mapea (HTTP method, endpoint path) → {menu_id, accion_id, menu_codigo,
menu_nombre, accion_codigo, accion_nombre, modulo_id}.
NO es una tabla local: es una vista cacheada de core_sigfa que se
reconstruye periódicamente. Si el catálogo cambia, el próximo refresh lo
refleja.
"""
REFRESH_INTERVAL = timedelta(minutes=5)
def __init__(self):
self._cache: dict[tuple[str, str], dict] = {}
self._last_refresh: datetime = datetime.min
self._module_ids: dict[str, uuid.UUID] = {}
def resolve(self, method: str, path: str) -> dict | None:
"""Resuelve un endpoint a sus datos de auditoría del catálogo."""
self._maybe_refresh()
key = (method, self._normalize(path))
return self._cache.get(key)
def _normalize(self, path: str) -> str:
"""
Normaliza el path para matching:
/api/v1/facturas/123 → /api/v1/facturas/{id}
/api/v1/facturas → /api/v1/facturas
"""
parts = path.strip("/").split("/")
normalized = []
for i, part in enumerate(parts):
if i >= 3: # después de /api/v1/
if part.isdigit() or (len(part) == 36 and "-" in part):
normalized.append("{id}")
else:
normalized.append(part)
else:
normalized.append(part)
return "/" + "/".join(normalized)
def _maybe_refresh(self):
now = datetime.now(timezone.utc)
if now - self._last_refresh > self.REFRESH_INTERVAL:
self._refresh()
def _refresh(self):
"""
Consulta core_sigfa para reconstruir el caché.
JOIN: cfg_menu (para modulo_id, codigo, nombre)
+ cfg_menu_acciones (para accion)
+ cfg_modulos (para nombre del módulo)
Filtra por is_visible=true, is_activo=true en ambas tablas.
"""
# Implementación que consulta core_sigfa vía HTTP o conexión directa
# y reconstruye self._cache y self._module_ids
...
self._last_refresh = datetime.now(timezone.utc)
Resolución automática de acciones por HTTP method¶
La regla por defecto (sin decorador) asigna la acción según el HTTP method:
| HTTP Method | accion_codigo |
accion_nombre |
Cuándo |
|---|---|---|---|
POST |
crear |
Crear | Cualquier POST |
PUT |
editar |
Editar | Actualización completa |
PATCH |
editar |
Editar | Actualización parcial |
DELETE |
eliminar |
Eliminar | Cualquier DELETE |
GET (sin {id}) |
listar |
Listar | Listados/paginación |
GET (con {id}) |
ver |
Ver | Consulta de un registro |
¿Y las acciones especiales? (aprobar, exportar, conciliar, etc.)
Se usan cuando el endpoint no es CRUD estándar:
# POST /api/v1/pedidos/{id}/aprobar → no es "crear", es "aprobar"
@router.post("/api/v1/pedidos/{pedido_id}/aprobar")
@audit(accion_codigo="aprobar", accion_nombre="Aprobar pedido")
def aprobar_pedido(pedido_id: str, ...):
...
# GET /api/v1/reportes/gerencial → no es "listar", es "exportar"
@router.get("/api/v1/reportes/gerencial")
@audit(accion_codigo="exportar", accion_nombre="Exportar reporte gerencial",
sensitive=True)
def exportar_gerencial(...):
...
El decorador (opcional — solo enriquecimiento)¶
El decorador no es obligatorio. Solo se usa cuando el endpoint necesita algo que el middleware no puede deducir automáticamente:
from app.audit import audit
# CASO 1: Acción especial (override de POST→crear)
@router.post("/api/v1/pedidos/{pedido_id}/aprobar")
@audit(accion_codigo="aprobar", accion_nombre="Aprobar pedido")
async def aprobar_pedido(pedido_id: str): ...
# CASO 2: Lectura sensible (GET que normalmente no audita)
@router.get("/api/v1/cartera")
@audit(sensitive=True)
async def ver_cartera(): ...
# CASO 3: Contexto de negocio adicional
@router.put("/api/v1/inventario/productos/{producto_id}")
@audit(
referencia_tipo="producto",
detalle="Actualización de precio de 10.00 a 12.50"
)
async def actualizar_producto(producto_id: str, data: ProductoUpdate): ...
# CASO 4: Multiple archivos
@router.post("/api/v1/facturas/{factura_id}/archivos")
@audit(referencia_tipo="factura")
async def subir_archivos(factura_id: str, archivos: list[UploadFile]): ...
Lo que hace el decorador:
- No conoce la lógica de negocio (no sabe qué cambió exactamente).
- Sobreescribe los valores resueltos por el middleware (solo los campos que se especifican; los demás quedan los del middleware).
- Si el endpoint falla con error HTTP, registra el intento con el estado fallido (ej. código de error y mensaje) salvo que el error sea un 401/403 de autenticación (no auditar los rechazos de auth por separado, ya los maneja la capa de seguridad).
# svc-contable/app/audit/__init__.py
import functools
from app.audit.service import AuditService
def audit(
accion_codigo: str | None = None,
accion_nombre: str | None = None,
referencia_tipo: str | None = None,
sensitive: bool = False,
detalle: str | None = None,
):
"""
Decorador opcional para enriquecer la auditoría del middleware.
Solo overridea los campos que se especifican. Los demás quedan con
los valores resueltos automáticamente por AuditMiddleware.
Uso:
@audit(accion_codigo="aprobar", sensitive=True)
"""
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get("request") or _get_request(args)
if request:
# Guarda los overrides en request.state para que
# AuditMiddleware los lea después del request
request.state.audit_overrides = {
k: v for k, v in {
"accion_codigo": accion_codigo,
"accion_nombre": accion_nombre,
"referencia_tipo": referencia_tipo,
"sensitive": sensitive,
"detalle": detalle,
}.items() if v is not None
}
return await func(*args, **kwargs)
return wrapper
return decorator
Subida de archivos (AuditContext)¶
Problema: el middleware corre antes del endpoint, pero la información de archivos (nombre, ruta, hash, tamaño) se conoce después de que el endpoint procesa el upload. Además, el middleware no puede leer el body multipart porque lo consumiría y el endpoint no podría recibirlo.
Solución: un AuditContext que el endpoint llena después de procesar
el request. El middleware lo lee al final y lo incluye en el registro.
# svc-contable/app/audit/context.py
from dataclasses import dataclass, field
import uuid
@dataclass
class AuditFile:
"""
Representa un archivo en el contexto de auditoría.
Solo `nombre` es requerido. Los demás campos son opcionales y dependen
de lo que el endpoint quiera registrar. El campo `meta` es un dict
libre para metadata adicional del servicio (ej. versiones, estados,
lotes, etc.).
"""
nombre: str
ruta: str | None = None # ruta local o URL de S3
tipo: str | None = None # MIME type
hash: str | None = None # sha256:...
tamano_bytes: int | None = None
descripcion: str | None = None # descripción individual del archivo
meta: dict | None = None # metadata libre adicional
@dataclass
class AuditContext:
"""
Contexto de auditoría que el endpoint llena después de procesar.
Accesible desde request.state.audit_ctx (inyectado por el middleware).
El middleware lee este contexto al final y lo incluye en audit_log.
Ejemplos de uso:
- Endpoint de upload: ctx.add_archivo(nombre="f-001.pdf", ruta="s3://...")
- Endpoint de update: ctx.datos_antes = {...}, ctx.datos_despues = {...}
- Endpoint de delete: ctx.detalle = "Se eliminó el registro X"
"""
archivos: list[AuditFile] = field(default_factory=list)
referencia_tipo: str | None = None
referencia_id: uuid.UUID | None = None
detalle: str | None = None
datos_antes: dict | None = None
datos_despues: dict | None = None
def add_archivo(
self,
nombre: str,
ruta: str | None = None,
tipo: str | None = None,
hash: str | None = None,
tamano_bytes: int | None = None,
descripcion: str | None = None,
meta: dict | None = None,
):
"""Helper para agregar un archivo al contexto."""
self.archivos.append(AuditFile(
nombre=nombre, ruta=ruta, tipo=tipo,
hash=hash, tamano_bytes=tamano_bytes,
descripcion=descripcion, meta=meta,
))
def add_archivos_s3(self, archivos_s3: list[dict]):
"""
Helper para agregar múltiples archivos desde S3.
Cada dict debe tener al menos 'nombre' y 'ruta' (URL de S3).
Ejemplo:
ctx.add_archivos_s3([
{"nombre": "f-001.pdf", "ruta": "https://s3.amazonaws.com/...", "descripcion": "Factura"},
{"nombre": "nc-001.pdf", "ruta": "https://s3.amazonaws.com/...", "descripcion": "Nota de crédito"},
])
"""
for item in archivos_s3:
self.archivos.append(AuditFile(
nombre=item["nombre"],
ruta=item.get("ruta"),
tipo=item.get("tipo"),
hash=item.get("hash"),
tamano_bytes=item.get("tamano_bytes"),
descripcion=item.get("descripcion"),
meta=item.get("meta"),
))
El middleware inyecta el contexto en request.state y lo lee al final:
# En AuditMiddleware.dispatch(), después del request:
audit_ctx = getattr(request.state, "audit_ctx", AuditContext())
if audit_ctx.archivos:
audit_data["archivos"] = [
{k: v for k, v in {
"nombre": f.nombre,
"ruta": f.ruta,
"tipo": f.tipo,
"hash": f.hash,
"tamano_bytes": f.tamano_bytes,
"descripcion": f.descripcion,
"meta": f.meta,
}.items() if v is not None}
for f in audit_ctx.archivos
]
if audit_ctx.referencia_tipo:
audit_data["referencia_tipo"] = audit_ctx.referencia_tipo
if audit_ctx.referencia_id:
audit_data["referencia_id"] = audit_ctx.referencia_id
if audit_ctx.detalle:
audit_data["detalle"] = audit_ctx.detalle
if audit_ctx.datos_antes:
audit_data["datos_antes"] = audit_ctx.datos_antes
if audit_ctx.datos_despues:
audit_data["datos_despues"] = audit_ctx.datos_despues
El endpoint llena el contexto después de procesar el upload:
# CASO 1: Subida simple — 1 archivo con descripción
@router.post("/api/v1/facturas/{factura_id}/archivos")
@audit(referencia_tipo="factura")
async def subir_archivo(
factura_id: str,
archivo: UploadFile,
request: Request
):
# 1. Guarda en S3
url_s3 = await s3_service.subir(archivo, carpeta="facturas")
# 2. Llena el contexto de auditoría
ctx: AuditContext = request.state.audit_ctx
ctx.add_archivo(
nombre=archivo.filename,
ruta=url_s3, # URL de S3
tipo=archivo.content_type,
tamano_bytes=archivo.size,
descripcion="Factura original del proveedor" # descripción individual
)
ctx.referencia_id = uuid.UUID(factura_id)
return {"ok": True, "url": url_s3}
# CASO 2: Múltiples archivos, cada uno con su descripción
@router.post("/api/v1/pedidos/{pedido_id}/documentos")
@audit(referencia_tipo="pedido", accion_codigo="subir_archivo")
async def subir_documentos(
pedido_id: str,
archivos: list[UploadFile],
descripciones: list[str], # el frontend manda las descripciones
request: Request
):
ctx: AuditContext = request.state.audit_ctx
for archivo, desc in zip(archivos, descripciones):
url_s3 = await s3_service.subir(archivo, carpeta="pedidos")
ctx.add_archivo(
nombre=archivo.filename,
ruta=url_s3,
tipo=archivo.content_type,
tamano_bytes=archivo.size,
descripcion=desc # cada archivo con su descripción
)
ctx.referencia_id = uuid.UUID(pedido_id)
return {"ok": True}
# CASO 3: Archivos + metadata libre via campo meta
@router.post("/api/v1/calidad/inspecciones/{inspeccion_id}/evidencias")
@audit(referencia_tipo="inspeccion")
async def subir_evidencias(
inspeccion_id: str,
archivos: list[UploadFile],
request: Request
):
ctx: AuditContext = request.state.audit_ctx
for archivo in archivos:
url_s3 = await s3_service.subir(archivo, carpeta="calidad")
ctx.add_archivo(
nombre=archivo.filename,
ruta=url_s3,
tipo=archivo.content_type,
tamano_bytes=archivo.size,
meta={ # metadata libre
"tipo_evidencia": "foto",
"lote": "L-2026-001",
"estado_calidad": "pendiente_revision"
}
)
ctx.referencia_id = uuid.UUID(inspeccion_id)
return {"ok": True}
# CASO 4: Helper para múltiples archivos desde S3
@router.post("/api/v1/compras/{compra_id}/adjuntos")
@audit(referencia_tipo="compra")
async def subir_adjuntos(
compra_id: str,
archivos: list[UploadFile],
request: Request
):
ctx: AuditContext = request.state.audit_ctx
# Sube todos a S3 y agrega de una
archivos_s3 = []
for archivo in archivos:
url = await s3_service.subir(archivo, carpeta="compras")
archivos_s3.append({
"nombre": archivo.filename,
"ruta": url,
"tipo": archivo.content_type,
"tamano_bytes": archivo.size,
})
ctx.add_archivos_s3(archivos_s3) # helper para bulk
ctx.referencia_id = uuid.UUID(compra_id)
return {"ok": True}
# CASO 3: Endpoint SIN archivos (el middleware no pone archivos en audit_log)
@router.put("/api/v1/inventario/productos/{producto_id}")
@audit(referencia_tipo="producto")
async def actualizar_producto(
producto_id: str,
data: ProductoUpdate,
request: Request
):
# 1. Captura estado anterior
ctx: AuditContext = request.state.audit_ctx
anterior = await service.obtener(producto_id)
ctx.datos_antes = {"precio": anterior.precio}
# 2. Actualiza
await service.actualizar(producto_id, data)
# 3. Captura estado posterior
ctx.datos_despues = {"precio": data.precio}
ctx.detalle = f"Precio de {anterior.precio} a {data.precio}"
ctx.referencia_id = uuid.UUID(producto_id)
return {"ok": True}
# CASO 5: Service que maneja archivos internamente
# (el repository reporta al contexto vía el service)
# service.py
class FacturaService:
def __init__(self, db: Session, audit_ctx: AuditContext):
self.db = db
self.audit_ctx = audit_ctx
def subir_adjunto(self, factura_id: str, archivo: UploadFile):
url_s3 = self._subir_a_s3(archivo, carpeta="facturas")
# Reporta al contexto de auditoría
self.audit_ctx.add_archivo(
nombre=archivo.filename,
ruta=url_s3,
tipo=archivo.content_type,
tamano_bytes=archivo.size,
descripcion="Adjunto de factura"
)
return url_s3
# router.py
@router.post("/api/v1/facturas/{factura_id}/adjuntos")
@audit(referencia_tipo="factura")
async def subir_adjunto(
factura_id: str,
archivo: UploadFile,
request: Request,
db: Session = Depends(get_db),
):
ctx: AuditContext = request.state.audit_ctx
service = FacturaService(db, ctx)
service.subir_adjunto(factura_id, archivo)
ctx.referencia_id = uuid.UUID(factura_id)
return {"ok": True, "url": url_s3}
Regla: si el endpoint no llena el contexto (no hay archivos, ni detalle, ni datos antes/después), el middleware igualmente registra la operación — solo sin los campos de enriquecimiento. La auditoría nunca deja de grabarse por falta de contexto.
Garantías¶
- Cobertura total: el middleware captura el 100% de las peticiones HTTP. No se escapa ningún endpoint, aunque sea nuevo y no tenga decorator.
- Escritura asíncrona: la persistencia se hace por trabajo/outbox (regla
4 del ADR 0003) para no sumar latencia a la transacción de negocio. El
AuditService.grabar()encola un evento; un consumidor lo persiste enaudit_logconempresa_idy RLS (ADR 0002). - Lecturas sensibles: solo ciertos GET se marcan como sensibles (el
decorador acepta
sensitive=True); el listado común no audita lecturas. - Sin lógica en cada módulo: el Service de negocio no inserta en
audit_log; solo entrega alAuditServicequé cambió (detalle, args). La capa de persistencia real del audit es única. - Los IDs vienen del catálogo (
cfg_menu,cfg_menu_accionesdel ADR 0008/0013) — nunca se construyen en el endpoint ni se guardan como texto. Los nombres se copian en el registro como snapshot del momento (ADR 0014): el historial se lee con un soloSELECTlocal, sin consultarcore_sigfa(ADR 0003). - Snapshot del módulo:
modulo_id,modulo_codigoymodulo_nombrese copian del mapping al momento de grabar, permitiendo filtrar por módulo sin JOIN acore_sigfa. - Separación por producto:
AuditServiceescribe solo en la BD del producto que lo invoca (ERP o CRM), sin cruces (ADR 0003). - Participación del patrón Router → Service → Repository (ADR 0001):
el middleware es parte de la capa de infraestructura (antes del Router);
el
AuditServicees un Service; la persistencia ocurre vía Repository del dominio de auditoría.
Ejemplo completo: endpoint CRUD SIN decorador¶
POST /api/v1/facturas (svc-contable, empresa_id=1)
Middleware resuelve automáticamente:
├─ del Request: metodo_http=POST, endpoint=/api/v1/facturas
├─ del JWT: actor_user_id=uuid, username="d.alcivar",
│ nombre="Duval Alcivar", email="d@sigfa.com",
│ empresa_id=1
├─ del MODULE_MAP: modulo_codigo="contabilidad",
│ modulo_nombre="Contabilidad"
├─ del catálogo: menu_id=uuid("facturas"), accion_id=uuid("crear"),
│ menu_codigo="facturas", menu_nombre="Facturación",
│ accion_codigo="crear", accion_nombre="Crear
└─ post-request: status_code=201
Registro en audit_log:
modulo_codigo = "contabilidad"
modulo_nombre = "Contabilidad"
menu_codigo = "facturas"
menu_nombre = "Facturación"
accion_codigo = "crear"
accion_nombre = "Crear"
actor_username = "d.alcivar"
actor_nombre = "Duval Alcivar"
metodo_http = POST
endpoint = /api/v1/facturas
empresa_id = 1
fecha = 2026-09-10T14:30:00Z
Ejemplo completo: endpoint CON decorador (acción especial)¶
POST /api/v1/pedidos/abc-123/aprobar (svc-pedidos, empresa_id=1)
Decorador declara: accion_codigo="aprobar", accion_nombre="Aprobar pedido"
Middleware resuelve:
├─ del Request: metodo_http=POST, endpoint=/api/v1/pedidos/{id}/aprobar
├─ del JWT: actor_user_id=uuid, username="j.garcia", ...
├─ del MODULE_MAP: modulo_codigo="pedidos", modulo_nombre="Pedidos"
├─ del catálogo: menu_id=uuid("pedidos"), menu_codigo="pedidos"
├─ del decorador: accion_codigo="aprobar" (override de POST→crear)
└─ post-request: status_code=200
Registro en audit_log:
modulo_codigo = "pedidos"
menu_codigo = "pedidos"
accion_codigo = "aprobar" ← del decorador, NO "crear"
accion_nombre = "Aprobar pedido" ← del decorador
actor_username = "j.garcia"
metodo_http = POST
endpoint = /api/v1/pedidos/abc-123/aprobar
empresa_id = 1
Ejemplo completo: lectura sensible¶
GET /api/v1/cartera (svc-contable, empresa_id=1)
Decorador declara: sensitive=True
Middleware resuelve:
├─ del Request: metodo_http=GET, endpoint=/api/v1/cartera
├─ del JWT: actor_user_id=uuid, username="admin", ...
├─ del MODULE_MAP: modulo_codigo="contabilidad"
├─ del catálogo: menu_id=uuid("cartera"), menu_codigo="cartera"
│ accion_codigo="ver", accion_nombre="Ver"
├─ del decorador: sensitive=True
└─ post-request: status_code=200
Resultado: SÍ se registra en audit_log (porque sensitive=True).
Sin el decorador, GET /api/v1/cartera NO se auditaría (lectura común).
Cómo identifica el módulo: el problema del path ambiguo¶
El path del endpoint no siempre coincide con el módulo en cfg_modulos:
| Endpoint | Path | Módulo en cfg_modulos |
|---|---|---|
svc-contable |
/api/v1/facturas |
contabilidad |
svc-contable |
/api/v1/cuentas_por_cobrar |
contabilidad |
svc-rrhh |
/api/v1/nomina |
rrhh |
svc-rrhh |
/api/v1/marcaciones |
rrhh |
svc-operaciones |
/api/v1/productos |
inventario |
svc-pedidos |
/api/v1/cotizaciones |
ventas |
El mapping por servicio resuelve esto: cada servicio sabe a qué módulo pertenece cada endpoint. Es un archivo pequeño (~5-15 líneas) que se carga una vez al startup.
¿Qué pasa si un endpoint no está en el mapping? Se registra sin datos
de módulo (modulo_codigo=NULL, modulo_nombre=NULL). El registro de
auditoría se crea igual (con actor, endpoint, método, fecha), pero sin la
dimensión de módulo. Esto permite que la auditoría nunca deje de funcionar,
aunque el mapping esté incompleto.
Cómo se identifica el producto (ERP vs CRM)¶
Cada servicio ya sabe en qué base de datos escribe (lo tiene en su
config.py). El AuditService usa la misma conexión:
# svc-contable/app/audit/service.py
class AuditService:
@staticmethod
async def grabar(data: dict):
"""
Escribe en erp_sigfa (svc-contable siempre es ERP).
svc-clientes escribe en crm_sigfapro.
"""
# El servicio ya tiene configurada su BD
# AuditService usa la misma conexión/replica
...
No hace falta identificar el producto en el middleware; cada servicio escribe en su propia base por diseño (ADR 0003).
Alternativas descartadas¶
- Decorador
@auditobligatorio en cada endpoint — descartado: tedioso, propenso a olvidos, obliga al desarrollador a conocer los códigos del catálogo. El middleware automático cubre el 100% sin código manual. - Middleware HTTP global sin mapping de módulos — descartado: captura actor y endpoint, pero no puede resolver a qué módulo pertenece sin un mapping explícito. El mapping por servicio resuelve esto con un costo mínimo.
- Insertar
audit_logmanualmente en cada método del Service — descartado: se olvida, se duplica lógica y viola la responsabilidad única. - Persistir en la misma transacción del negocio — descartado: suma latencia y riesgos de un fail conjunto; la escritura asíncrona aísla el fallo de auditoría del resultado del negocio.
- Triggers de BD — descartado (mismo criterio del ADR 0008: sin contexto de usuario/acción funcional ni página).
- Auto-resolución del módulo por parsing del path — descartado: el path
del API no siempre coincide con el código del módulo en
cfg_modulos. Un mapping explícito es más confiable y auditado.
Consecuencias¶
- Ganancia: cobertura total de auditoría sin código manual en el 90% de los endpoints. El middleware captura todo; el decorador solo enriquece.
- Ganancia: una sola forma de auditar en todo el backend → consistencia y regla de revisión en PR: "todo endpoint nuevo debe estar en el MODULE_MAP de su servicio".
- Ganancia: el desarrollador no necesita conocer los códigos del catálogo de navegación para auditar un CRUD estándar.
- Costo: cada servicio debe mantener un
MODULE_MAP(~5-15 líneas) con sus endpoints → módulos. Este mapping se revisa en PR como cualquier configuración. - Costo: el
CatalogCacheconsultacore_sigfaal startup y cada 5 minutos. Si el catálogo cambia mientras el servicio está corriendo, tarda hasta 5 minutos en reflejarlo (aceptable para configuración que cambia raramente). - Costo de implementar el middleware +
AuditServiceuna vez en el core shared y consumirlo desde cada servicio; la política de lecturas sensibles y el catálogo inicial demenu_id/accion_idquedan para definición por módulo. - El fallo del consumidor de auditoría no hace fallar la operación de negocio (cola tolerante; se monitorea la cola como un contrato más).