Saltar a contenido

Especificación Técnica: audit_log

Esquema: erp
Base de Datos: core
Servicio: svc-identidad
Tabla Legacy Origen: bd_erp.audit_log
Propósito: Bitácora de auditoría particionada por mes, ubicada en la base erp de cada producto (fuera de core).


1. Justificación y Mejoras de Arquitectura

  • Vive en el esquema erp de la base del producto (no en core); no comparte transaccionabilidad con el núcleo.
  • Particionada por rango de fecha (mes) con PK compuesta (id, fecha) para escritura mantenible.
  • Snapshot de nombres y códigos (modulo/menu/accion/actor) para auditar aunque el origen sea eliminado.
  • JSONB datos_antes/datos_despues/archivos para antes-después y adjuntos.

2. Definiciones de Implementación

Table erp.audit_log { id uuid [pk, default: gen_random_uuid()] empresa_id bigint [not null] fecha timestamptz [not null, default: now()] menu_id uuid [not null] accion_id uuid [not null] modulo_id uuid actor_user_id uuid [not null] actor_username varchar [not null] actor_nombre varchar [not null] actor_email varchar [not null] modulo_codigo varchar modulo_nombre varchar menu_codigo varchar menu_nombre varchar accion_codigo varchar accion_nombre varchar metodo_http varchar endpoint varchar referencia_tipo varchar referencia_id uuid detalle text datos_antes jsonb datos_despues jsonb archivos jsonb trace_id uuid created_at timestamptz [default: now()]

Indexes {
    (empresa_id) [name: 'ix_audit_log_empresa_id']
    (empresa_id, fecha) [name: 'ix_audit_log_empresa_fecha']
    (actor_user_id) [name: 'ix_audit_log_actor_user_id']
    (menu_id) [name: 'ix_audit_log_menu_id']
    (modulo_codigo) [name: 'ix_audit_log_modulo_codigo']
    (referencia_tipo, referencia_id) [name: 'ix_audit_log_referencia']
    (trace_id) [name: 'ix_audit_log_trace_id']
}

}

CREATE TABLE IF NOT EXISTS erp.audit_log (
    id                  UUID           NOT NULL DEFAULT gen_random_uuid(),
    empresa_id          BIGINT         NOT NULL,
    fecha               TIMESTAMPTZ    NOT NULL DEFAULT now(),
    menu_id             UUID           NOT NULL,
    accion_id           UUID           NOT NULL,
    modulo_id           UUID,
    actor_user_id       UUID           NOT NULL,
    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),
    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)
) PARTITION BY RANGE (fecha);

-- Índices
CREATE INDEX IF NOT EXISTS ix_audit_log_empresa_id ON erp.audit_log (empresa_id);
CREATE INDEX IF NOT EXISTS ix_audit_log_empresa_fecha ON erp.audit_log (empresa_id, fecha);
CREATE INDEX IF NOT EXISTS ix_audit_log_actor_user_id ON erp.audit_log (actor_user_id);
CREATE INDEX IF NOT EXISTS ix_audit_log_menu_id ON erp.audit_log (menu_id);
CREATE INDEX IF NOT EXISTS ix_audit_log_modulo_codigo ON erp.audit_log (modulo_codigo);
CREATE INDEX IF NOT EXISTS ix_audit_log_referencia ON erp.audit_log (referencia_tipo, referencia_id);
CREATE INDEX IF NOT EXISTS ix_audit_log_trace_id ON erp.audit_log (trace_id);

-- Las particiones mensuales (audit_log_YYYYMM) se crean operativamente
-- previo al rango de fechas a registrar.

-- Comentarios
COMMENT ON TABLE erp.audit_log IS 'Bitácora de auditoría particionada por mes (fecha) del dominio identidad.';
from datetime import datetime
from typing import Optional
import uuid

from sqlalchemy import (
    BigInteger,
    CheckConstraint,
    DateTime,
    Index,
    String,
    Text,
    UniqueConstraint,
    text,
)
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


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(
        postgresql.UUID(as_uuid=True),
        primary_key=True,
        server_default=text("gen_random_uuid()"),
    )
    empresa_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
    fecha: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, server_default=text("now()")
    )
    menu_id: Mapped[uuid.UUID] = mapped_column(postgresql.UUID(as_uuid=True), nullable=False)
    accion_id: Mapped[uuid.UUID] = mapped_column(postgresql.UUID(as_uuid=True), nullable=False)
    modulo_id: Mapped[Optional[uuid.UUID]] = mapped_column(postgresql.UUID(as_uuid=True))
    actor_user_id: Mapped[uuid.UUID] = mapped_column(postgresql.UUID(as_uuid=True), nullable=False)
    actor_username: Mapped[Optional[str]] = mapped_column(String(50))
    actor_nombre: Mapped[Optional[str]] = mapped_column(String(200))
    actor_email: Mapped[Optional[str]] = mapped_column(String(150))
    modulo_codigo: Mapped[Optional[str]] = mapped_column(String(50))
    modulo_nombre: Mapped[Optional[str]] = mapped_column(String(150))
    menu_codigo: Mapped[Optional[str]] = mapped_column(String(50))
    menu_nombre: Mapped[Optional[str]] = mapped_column(String(150))
    accion_codigo: Mapped[Optional[str]] = mapped_column(String(50))
    accion_nombre: Mapped[Optional[str]] = mapped_column(String(100))
    metodo_http: Mapped[Optional[str]] = mapped_column(String(10))
    endpoint: Mapped[Optional[str]] = mapped_column(String(300))
    referencia_tipo: Mapped[Optional[str]] = mapped_column(String(50))
    referencia_id: Mapped[Optional[uuid.UUID]] = mapped_column(postgresql.UUID(as_uuid=True))
    detalle: Mapped[Optional[str]] = mapped_column(Text)
    datos_antes: Mapped[Optional[object]] = mapped_column(postgresql.JSONB)
    datos_despues: Mapped[Optional[object]] = mapped_column(postgresql.JSONB)
    archivos: Mapped[Optional[object]] = mapped_column(postgresql.JSONB)
    trace_id: Mapped[Optional[uuid.UUID]] = mapped_column(postgresql.UUID(as_uuid=True))
    created_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), server_default=text("now()")
    )
"""revision: core_id_0023
create table erp.audit_log (particionado por rango de fecha)

"""

from alembic import op
import sqlalchemy as sa


revision: str = "core_id_0023"
down_revision: str | None = "core_id_0022"
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
    op.execute(
        """
        CREATE TABLE IF NOT EXISTS erp.audit_log (
            id                  UUID           NOT NULL DEFAULT gen_random_uuid(),
            empresa_id          BIGINT         NOT NULL,
            fecha               TIMESTAMPTZ    NOT NULL DEFAULT now(),
            menu_id             UUID           NOT NULL,
            accion_id           UUID           NOT NULL,
            modulo_id           UUID,
            actor_user_id       UUID           NOT NULL,
            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),
            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)
        ) PARTITION BY RANGE (fecha);
        """
    )
    op.create_index("ix_audit_log_empresa_id", "audit_log", ["empresa_id"], schema="erp")
    op.create_index("ix_audit_log_empresa_fecha", "audit_log", ["empresa_id", "fecha"], schema="erp")
    op.create_index("ix_audit_log_actor_user_id", "audit_log", ["actor_user_id"], schema="erp")
    op.create_index("ix_audit_log_menu_id", "audit_log", ["menu_id"], schema="erp")
    op.create_index("ix_audit_log_modulo_codigo", "audit_log", ["modulo_codigo"], schema="erp")
    op.create_index("ix_audit_log_referencia", "audit_log", ["referencia_tipo", "referencia_id"], schema="erp")
    op.create_index("ix_audit_log_trace_id", "audit_log", ["trace_id"], schema="erp")


def downgrade() -> None:
    op.drop_table("audit_log", schema="erp")