0014. Modelo de datos: auditoría (audit_log)¶
Estado: Propuesto Fecha: 2026-09-04 Autor: Duval Alcivar Módulo(s) afectado(s): todos (ERP y CRM) — transversal
Contexto¶
Los ADRs 0008 (auditoría) y 0009 (captura backend) definen la bitácora de
movimientos. Este ADR fija el modelo de datos de audit_log, que vive
en cada base de datos de producto (una por ERP y otra por CRM) y se
particiona por rango mensual. La navegación que la alimenta está en el ADR
0013.
Decisión¶
audit_log registra cada acción del usuario: quién, cuándo, qué acción, en
qué página/módulo, sobre qué registro, con qué datos y qué archivos. Además
de los identificadores (UUID), guarda una copia legible del momento: el
username/nombre/email del actor y los códigos/nombres de la página y de la
acción tal como eran cuando ocurrió el movimiento, para que el historial se
lea sin consultar otras bases (ADR 0003) y refleje lo que se vio en esa
pantalla entonces.
Ubicación¶
| Tabla | BD | Esquema | Dueño |
|---|---|---|---|
audit_log |
erp_sigfa / crm_sigfapro |
erp.audit / crm.audit |
cada producto |
Una por producto, sin cruce entre bases (ADR 0003): cada BD mantiene su propia bitácora.
menu_id/accion_idapuntan acfg_menu/cfg_menu_accionesque viven encore_sigfa(otra BD), así que se correlacionan por UUID, sin FK entre bases; sus nombres se copian en el registro como snapshot en el momento de grabar.
Tabla audit_log¶
Campos agrupados en identificación (UUID: correlación estable), datos del momento (texto copiado al grabar: lo que se vio en pantalla entonces) y detalle del cambio.
| Campo | Tipo | Restricciones | Notas |
|---|---|---|---|
id |
UUID | PK compuesta: (id, fecha) |
DEFAULT gen_random_uuid() |
empresa_id |
BIGINT | NOT NULL | RLS |
fecha |
TIMESTAMPTZ | NOT NULL, DEFAULT now() | columna de partición |
menu_id |
UUID | NOT NULL | correlación → cfg_menu (core, sin FK entre BD) |
accion_id |
UUID | NOT NULL | correlación → cfg_menu_acciones (core, sin FK entre BD) |
modulo_id |
UUID | correlación → cfg_modulos (core, sin FK entre BD) |
|
actor_user_id |
UUID | NOT NULL | quién lo hizo (UUID del token) |
actor_username |
VARCHAR(50) | username del actor en ese momento | |
actor_nombre |
VARCHAR(200) | nombre del actor en ese momento | |
actor_email |
VARCHAR(150) | email del actor en ese momento | |
modulo_codigo |
VARCHAR(50) | código del módulo en ese momento (ej. inventario, contabilidad) |
|
modulo_nombre |
VARCHAR(150) | nombre del módulo en ese momento | |
menu_codigo |
VARCHAR(50) | código de la página en ese momento (ej. nomina) |
|
menu_nombre |
VARCHAR(150) | nombre de la página en ese momento | |
accion_codigo |
VARCHAR(50) | código de la acción (ej. crear) |
|
accion_nombre |
VARCHAR(100) | nombre de la acción en ese momento | |
metodo_http |
VARCHAR(10) | POST, PUT, PATCH, DELETE, GET | |
endpoint |
VARCHAR(300) | endpoint del API (FastAPI) | |
referencia_tipo |
VARCHAR(50) | registro de negocio afectado en la misma BD: pedido, cliente, factura, ingreso |
|
referencia_id |
UUID | el registro de negocio afectado (su UUID): responde "¿qué le pasó a este pedido?" | |
detalle |
TEXT | descripción legible del cambio | |
datos_antes |
JSONB | snapshot previo (opcional) | |
datos_despues |
JSONB | snapshot posterior (opcional) | |
archivos |
JSONB | archivos del request: [{nombre, ruta, tipo, hash, tamano_bytes, descripcion, meta}] flexible, 0-N archivos |
|
trace_id |
UUID | trazabilidad distribuida (ADR 0003) | |
created_at |
TIMESTAMPTZ | DEFAULT now() |
Por qué se guarda el snapshot del momento¶
La auditoría es un historial, no un espejo del estado actual: muestra cómo estaban las cosas cuando ocurrió el movimiento, no cómo están hoy.
- Quién: si el usuario cambia de username, nombre o email, el movimiento
debe seguir mostrando quién lo hizo entonces.
actor_user_id(UUID) es la correlación estable;actor_username/actor_nombre/actor_emailson la copia legible del momento (tomada del token en el request). - En qué pantalla: si mañana se rearma el menú (
cfg_menu) o se renombra una acción, el histórico debe mostrar la página/acción que había cuando se registró.menu_id/accion_id(UUID) son la correlación;menu_codigo/menu_nombre/accion_codigo/accion_nombreson la copia legible del momento (tomada del catálogo de navegación al grabar). - En qué módulo: si mañana se reorganizan los módulos de la aplicación,
el histórico debe mostrar el módulo que existía cuando se registró.
modulo_id(UUID) es la correlación;modulo_codigo/modulo_nombreson la copia legible del momento (tomada del MODULE_MAP del servicio, ADR 0009). Esto permite filtrar "todo lo que se hizo en inventario" o "movimientos de contabilidad este mes" sin JOIN acore_sigfa— el módulo ya está en el snapshot. - Autonomía de lectura (ADR 0003): con esa copia, la vista de movimientos
en el ERP (o CRM) se arma con un solo
SELECTlocal aerp.audit_log/crm.audit_log— sin consultarcore_sigfani llamar a ninguna API para resolver nombres. Esa es la única forma de garantizar cero consultas cruzadas entre bases y a la vez mostrar el historial legible. referencia_tipo/referencia_id: identifican qué registro de negocio se afectó (ej.pedido+ su UUID). El registro vive en la misma base del producto (está enerp/crm), así que no rompe la regla de no cruzar bases, y permite filtrar "todo lo que se hizo sobre este pedido/cliente/factura". A diferencia deactor_*/modulo_*/menu_*/accion_*(copias legibles), estos dos apuntan al objeto de negocio afectado.- Regla de grabación: los campos de texto se llenan siempre en el momento
de grabar (del token, del MODULE_MAP y del catálogo). Si por un caso
extremo (legado) un nombre no se pudiera resolver, se inserta
NULLy se loguea el fallo — la auditoría nunca deja de grabarse por una resolución de nombre.
CREATE TABLE erp.audit_log (
id UUID NOT NULL DEFAULT gen_random_uuid(),
empresa_id BIGINT NOT NULL,
fecha TIMESTAMPTZ NOT NULL DEFAULT now(),
-- correlación estable (UUID, sin FK entre bases)
menu_id UUID NOT NULL,
accion_id UUID NOT NULL,
modulo_id UUID,
actor_user_id UUID NOT NULL,
-- datos del momento (snapshot): copia legible al grabar
actor_username VARCHAR(50),
actor_nombre VARCHAR(200),
actor_email VARCHAR(150),
modulo_codigo VARCHAR(50),
modulo_nombre VARCHAR(150),
menu_codigo VARCHAR(50),
menu_nombre VARCHAR(150),
accion_codigo VARCHAR(50),
accion_nombre VARCHAR(100),
-- detalle del cambio
metodo_http VARCHAR(10),
endpoint VARCHAR(300),
referencia_tipo VARCHAR(50),
referencia_id UUID,
detalle TEXT,
datos_antes JSONB,
datos_despues JSONB,
archivos JSONB,
trace_id UUID,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id, fecha) -- PK compuesta por partición
) PARTITION BY RANGE (fecha);
-- menu_id / accion_id / modulo_id se correlacionan por UUID a
-- cfg_menu / cfg_menu_acciones / cfg_modulos (core_sigfa, otra BD).
-- Sus nombres se copian en el momento de grabar como snapshot:
-- el historial se lee con un SELECT local, sin consultar core ni APIs.
CREATE INDEX ix_audit_log_empresa_id ON erp.audit_log (empresa_id);
CREATE INDEX ix_audit_log_empresa_fecha ON erp.audit_log (empresa_id, fecha);
CREATE INDEX ix_audit_log_actor_user_id ON erp.audit_log (actor_user_id);
CREATE INDEX ix_audit_log_menu_id ON erp.audit_log (menu_id);
CREATE INDEX ix_audit_log_modulo_codigo ON erp.audit_log (modulo_codigo);
CREATE INDEX ix_audit_log_referencia ON erp.audit_log (referencia_tipo, referencia_id);
CREATE INDEX ix_audit_log_trace_id ON erp.audit_log (trace_id);
-- RLS
ALTER TABLE erp.audit_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON erp.audit_log
USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
class AuditLogERP(Base):
__tablename__ = "audit_log"
__table_args__ = (
Index("ix_audit_log_empresa_id", "empresa_id"),
Index("ix_audit_log_empresa_fecha", "empresa_id", "fecha"),
Index("ix_audit_log_actor_user_id", "actor_user_id"),
Index("ix_audit_log_menu_id", "menu_id"),
Index("ix_audit_log_modulo_codigo", "modulo_codigo"),
Index("ix_audit_log_referencia", "referencia_tipo", "referencia_id"),
Index("ix_audit_log_trace_id", "trace_id"),
{"schema": "erp"},
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
empresa_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, nullable=False)
fecha: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True, nullable=False, server_default=func.now())
menu_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
accion_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
modulo_id: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
actor_user_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
actor_username: Mapped[str | None] = mapped_column(String(50))
actor_nombre: Mapped[str | None] = mapped_column(String(200))
actor_email: Mapped[str | None] = mapped_column(String(150))
modulo_codigo: Mapped[str | None] = mapped_column(String(50))
modulo_nombre: Mapped[str | None] = mapped_column(String(150))
menu_codigo: Mapped[str | None] = mapped_column(String(50))
menu_nombre: Mapped[str | None] = mapped_column(String(150))
accion_codigo: Mapped[str | None] = mapped_column(String(50))
accion_nombre: Mapped[str | None] = mapped_column(String(100))
metodo_http: Mapped[str | None] = mapped_column(String(10))
endpoint: Mapped[str | None] = mapped_column(String(300))
referencia_tipo: Mapped[str | None] = mapped_column(String(50))
referencia_id: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
detalle: Mapped[str | None] = mapped_column(Text)
datos_antes: Mapped[dict | None] = mapped_column(JSONB)
datos_despues: Mapped[dict | None] = mapped_column(JSONB)
archivos: Mapped[list | None] = mapped_column(JSONB)
trace_id: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
PK compuesta
(id, empresa_id, fecha)en SQLAlchemy (por elnaming_convention); en SQL PGPRIMARY KEY (id, fecha)por la partición.PARTITION BY RANGE (fecha)se aplica en la migración Alembic, no en el modelo ORM.
Particionamiento¶
audit_log se particiona por rango mensual sobre fecha (12 particiones
por año; permite purgar/archivar un mes entero con un solo DROP/detach).
CREATE TABLE erp.audit_log_2026_09 PARTITION OF erp.audit_log
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE erp.audit_log_2026_10 PARTITION OF erp.audit_log
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
PK compuesta:
PRIMARY KEY (id, fecha)porquefechaes la columna de partición. Sin partición para un rango, el INSERT falla; por eso las particiones se crean por adelantado, de forma automática con un job APScheduler (ver ADR 0015 — Tareas programadas).
Alternativas descartadas¶
- Una sola tabla de auditoría global compartida por ERP y CRM — descartado: viola la regla de cero consultas entre bases (ADR 0003).
- Particionado por LIST en
empresa_id— descartado: el patrón típico es "movimientos de un usuario/mes" →RANGE (fecha);empresa_idse indexa. - FK de
menu_id/accion_ida las tablas de navegación — descartado: viven en otra BD; se correlaciona por UUID (ADR 0003).
Consecuencias¶
- Ganancia: trazabilidad completa y centralizada por producto.
- Ganancia: el historial se lee con un solo
SELECTlocal — los nombres del actor, del módulo, de la página y de la acción se graban en el momento; cero consultas cruzadas ni llamadas por API para resolver el historial (ADR 0003). - Ganancia: el snapshot del módulo (
modulo_codigo/modulo_nombre) permite filtrar y agrupar por módulo sin JOIN acore_sigfa:WHERE modulo_codigo = 'inventario'oGROUP BY modulo_codigo, modulo_nombre. - Ganancia: el particionado se crea y cae solo, sin intervención manual ni extensiones del servidor (job APScheduler, ADR 0015).
- Costo:
audit_logcrece con cada escritura (y los campos snapshot agregan texto); partición mensual + job programado que hay que monitorear (si falla meses seguidos → alerta). - Costo: si el catálogo o el usuario cambian después, el histórico conserva los nombres del momento (correcto para auditoría), pero el snapshot solo es legible para lo que existía al grabar; si una pantalla se elimina por completo, su histórico conserva igual su nombre copiado.
- Pendiente: política de retención (tiempo/volumen) por producto.
Complementa los ADRs 0008 (auditoría), 0009 (captura backend) y 0010 (frontend) definiendo el modelo de datos de la bitácora. La navegación está en el ADR 0013.