feat: Implement a comprehensive audit log system with a dedicated dashboard page and backend API.

This commit is contained in:
Galindo97
2026-02-10 14:07:20 -06:00
parent 6b88429cc6
commit f90e20469a
14 changed files with 1163 additions and 7 deletions

View File

@@ -0,0 +1,116 @@
"""
Audit Log Events
"""
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session
from .services.service import AuditService
from core.context import get_user_context
def register_audit_listeners(models_to_audit):
"""
Register SQLAlchemy listeners for given models
"""
for model in models_to_audit:
event.listen(model, "after_insert", after_insert_listener)
event.listen(model, "after_update", after_update_listener)
event.listen(model, "after_delete", after_delete_listener)
def _get_current_username():
try:
context = get_user_context()
if context:
# Token usually has 'preferred_username' or 'name' or 'sub'
return context.get("preferred_username") or context.get("email") or context.get("sub") or "System"
except:
pass
return "System"
def after_insert_listener(mapper, connection, target):
"""
Listener for INSERT operations
"""
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None)
# Create a session bound to the connection
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="CREATE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
company_id=company_id
)
except Exception as e:
print(f"Error logging insert: {e}")
finally:
session.close()
def after_update_listener(mapper, connection, target):
"""
Listener for UPDATE operations
"""
table_name = target.__tablename__
state = inspect(target)
changes = {}
old_values = {}
new_values = {}
for attr in state.attrs:
hist = attr.history
if hist.has_changes():
changes[attr.key] = hist.added[0] if hist.added else None
old_values[attr.key] = hist.deleted[0] if hist.deleted else None
new_values[attr.key] = hist.added[0] if hist.added else None
if not changes:
return
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="UPDATE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
old_values=old_values,
new_values=new_values
)
except Exception as e:
print(f"Error logging update: {e}")
finally:
session.close()
def after_delete_listener(mapper, connection, target):
"""
Listener for DELETE operations
"""
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", ""))
)
except Exception as e:
print(f"Error logging delete: {e}")
finally:
session.close()

View File

@@ -0,0 +1,22 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from core.security import verify_token
from core.context import set_user_context
class UserContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ")[1]
try:
# verify_token might raise exception if invalid, we catch it to not block request
# but we won't have user context
user_info = verify_token(token)
set_user_context(user_info)
except Exception:
# Log error or ignore
pass
response = await call_next(request)
return response

View File

@@ -0,0 +1,56 @@
"""
Audit Log Models
"""
from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
# Primary Key
spec_id = Column(Integer, primary_key=True, autoincrement=True)
# Legacy Display Columns (English names as requested)
reference = Column(String(100), nullable=False, index=True) # Legacy: Referencia
procedure = Column(String(100), nullable=False, index=True) # Legacy: Procedimiento
movement = Column(String(255), nullable=False) # Legacy: Movimiento
username = Column(String(100), nullable=False, index=True) # Legacy: Usuario
date = Column(Date, nullable=False, index=True) # Legacy: Fecha
time = Column(Time, nullable=False) # Legacy: Hora
# Technical Columns
timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries
system = Column(String(20), nullable=False, index=True, default="SCAF")
company_id = Column(Integer, nullable=True, index=True)
tenant_id = Column(Integer, nullable=True, index=True)
# Traceability
table_name = Column(String(100), nullable=True, index=True)
record_id = Column(String(255), nullable=True, index=True)
operation_type = Column(String(20), nullable=True, index=True) # CREATE, UPDATE, DELETE, LOGIN
# Data Changes
old_values = Column(JSONB, nullable=True)
new_values = Column(JSONB, nullable=True)
changed_fields = Column(ARRAY(String), nullable=True)
# Request Context
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
endpoint = Column(String(500), nullable=True)
request_method = Column(String(10), nullable=True)
session_id = Column(String(50), nullable=True, index=True)
execution_time_ms = Column(Integer, nullable=True)
# Metadata
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Composite Indexes for common filters
__table_args__ = (
Index('idx_audit_username_date', 'username', 'date'),
Index('idx_audit_procedure_date', 'procedure', 'date'),
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
Index('idx_audit_table_record', 'table_name', 'record_id'),
)

View File

@@ -0,0 +1,93 @@
"""
Audit Log Router
"""
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import or_, desc, distinct
from core.database import get_core_db
from core.security import get_current_user # Assuming this exists
from .models import AuditLog
from .schemas import AuditLogListResponse, AuditLogResponse, AuditLogDetailResponse
router = APIRouter()
@router.get("/bitacora", response_model=AuditLogListResponse)
async def get_bitacora(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
search: Optional[str] = None,
username: Optional[str] = None,
procedure: Optional[str] = None,
reference: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_core_db)
):
"""
Get legacy audit log (Bitácora)
"""
query = db.query(AuditLog)
# Filters
if date_from:
query = query.filter(AuditLog.date >= date_from)
if date_to:
query = query.filter(AuditLog.date <= date_to)
if username:
query = query.filter(AuditLog.username.ilike(f"%{username}%"))
if procedure:
# Exact match for dropdown filter usually better, but let's allow partial if manual
# Legacy UI sends exact strings usually
query = query.filter(AuditLog.procedure == procedure)
if reference:
query = query.filter(AuditLog.reference.ilike(f"%{reference}%"))
if search:
# General search across main columns
search_filter = or_(
AuditLog.reference.ilike(f"%{search}%"),
AuditLog.procedure.ilike(f"%{search}%"),
AuditLog.movement.ilike(f"%{search}%"),
AuditLog.username.ilike(f"%{search}%")
)
query = query.filter(search_filter)
total = query.count()
# Sort by ID desc (newest first) -> Legacy usually shows newest first or spec_id desc
logs = query.order_by(desc(AuditLog.spec_id))\
.offset((page - 1) * page_size)\
.limit(page_size)\
.all()
return {
"data": logs,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/bitacora/procedimientos", response_model=List[str])
async def get_procedures(db: Session = Depends(get_core_db)):
"""
Get distinct list of procedures for filters
"""
results = db.query(distinct(AuditLog.procedure))\
.order_by(AuditLog.procedure)\
.all()
# verify if result is tuple
return [r[0] for r in results if r[0]]
@router.get("/bitacora/{spec_id}/detalle", response_model=AuditLogDetailResponse)
async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)):
"""
Get full detail of a log entry
"""
log = db.query(AuditLog).filter(AuditLog.spec_id == spec_id).first()
if not log:
raise HTTPException(status_code=404, detail="Log entry not found")
return log

View File

@@ -0,0 +1,52 @@
"""
Audit Log Schemas
"""
from typing import Optional, List, Any, Dict
from datetime import date as date_type, time as time_type, datetime
from pydantic import BaseModel, Field
# --- Response Schemas ---
class AuditLogResponse(BaseModel):
"""
Standard response showing the Legacy columns
"""
spec_id: int
reference: str
procedure: str
movement: str
username: str
date: date_type
time: time_type
# Modern extras
timestamp: datetime
system: str
operation_type: Optional[str] = None
table_name: Optional[str] = None
record_id: Optional[str] = None
class Config:
from_attributes = True
class AuditLogDetailResponse(AuditLogResponse):
"""
Detailed response including changed values
"""
old_values: Optional[Dict[str, Any]] = None
new_values: Optional[Dict[str, Any]] = None
changed_fields: Optional[List[str]] = None
ip_address: Optional[str] = None
execution_time_ms: Optional[int] = None
# --- List Response ---
class AuditLogListResponse(BaseModel):
"""
Paginated response
"""
data: List[AuditLogResponse]
total: int
page: int
page_size: int

View File

@@ -0,0 +1,148 @@
"""
Audit Log Core Logic: Reference Generation and Mapping
"""
from typing import Optional, Dict, Any, Tuple
class ReferenceGenerator:
"""
Generates legacy-style references (e.g., FUSE0-040-10)
"""
@staticmethod
def generate_invoice_reference(invoice_data: Dict[str, Any]) -> str:
"""
Format: {SYSTEM}-{CUSTOMS}-{YEAR}
Example: FUSE0-040-10
"""
# Default values
system = "FUSE0"
customs = "000"
year = "00"
# Try to extract system (invoice_type usually holds this key)
if invoice_data.get("invoice_type"):
system = str(invoice_data["invoice_type"])
# Try to extract customs (need to look into nested compliance_mx if available, or just use default)
# Since this receives a dictionary from the mapper, we might not have deep nested relations resolved
# We'll try to do our best with available data
# Try to get year from invoice_date
if invoice_data.get("invoice_date"):
try:
# invoice_date can be a date object or string
d = invoice_data["invoice_date"]
if hasattr(d, "year"):
y = d.year
else:
# Assume string YYYY-MM-DD
y = int(str(d)[:4])
year = str(y)[-2:]
except:
pass
return f"{system}-{customs}-{year}"
@staticmethod
def generate_invoice_item_reference(invoice_ref: str, item_data: Dict[str, Any]) -> str:
"""
Format: {INVOICE_REF}-{ITEM_PART}
Example: FUSE0-040-10-FUS035
"""
part_number = item_data.get("part_number", "ITEM")
return f"{invoice_ref}-{part_number}"
@staticmethod
def generate_pedimento_reference(pedimento_data: Dict[str, Any]) -> str:
"""
Format: {LICENSE}-{CUSTOMS}{YEAR}{NUMBER}
Example: 0756C-040010315
"""
license = str(pedimento_data.get("license", "0000")).strip()
customs = str(pedimento_data.get("customs_office", "000")).zfill(3)
year = str(pedimento_data.get("year", "00")).zfill(2)
number = str(pedimento_data.get("pedimento_number", "0000000")).zfill(7)
return f"{license}-{customs}{year}{number}"
class AuditMapper:
"""
Maps table names and operations to English Procedures and Movements
"""
# Map table names to Legacy Procedures (English)
TABLE_TO_PROCEDURE = {
# Invoices
"invoice_header": "IMPORT INVOICE BROWSE", # BROWSEOFACIMP
"invoice_sales_details": "IMPORT INVOICE UPDATE", # UPDATEOFACIMP
# Exports would be similar but we start with general
# Pedimentos
"pedimentos": "PEDIMENTO BROWSE", # BROWSEPEDIMEN
# System
"users": "SYSTEM SCAF",
"sessions": "SYSTEM SCAF",
# General fallbacks
"clients_and_providers": "CATALOGS",
"items": "CATALOGS",
}
# Map (Table, Operation) to Legacy Movements (English)
OPERATION_TO_MOVEMENT = {
("invoice_header", "CREATE"): "ADD IMPORT_INVOICE",
("invoice_header", "UPDATE"): "EDIT IMPORT_INVOICE",
("invoice_header", "DELETE"): "DELETE IMPORT_INVOICE",
("invoice_sales_details", "CREATE"): "ADD IMPORT_INVOICE_ITEM",
("invoice_sales_details", "UPDATE"): "EDIT IMPORT_INVOICE_ITEM",
("invoice_sales_details", "DELETE"): "DELETE IMPORT_INVOICE_ITEM",
("pedimentos", "CREATE"): "ADD PEDIMENTO",
("pedimentos", "UPDATE"): "EDIT PEDIMENTO",
("pedimentos", "DELETE"): "DELETE PEDIMENTO",
("auth", "LOGIN"): "SYSTEM LOGIN",
("auth", "LOGOUT"): "SYSTEM LOGOUT",
}
@staticmethod
def map_to_legacy_format(
table_name: str,
record_id: str,
operation_type: str,
username: str,
system: str = "SCAF"
) -> Dict[str, Any]:
"""
Returns dictionary with keys: reference, procedure, movement, username, system
"""
# Determine Procedure
procedure = AuditMapper.TABLE_TO_PROCEDURE.get(
table_name,
table_name.upper().replace("_", " ") # Fallback
)
# Determine Movement
movement_key = (table_name, operation_type)
movement = AuditMapper.OPERATION_TO_MOVEMENT.get(
movement_key,
f"{operation_type} {table_name.upper()}"
)
# Determine Reference Base
if operation_type in ["LOGIN", "LOGOUT"]:
reference = operation_type
else:
reference = record_id or "NO-REF"
return {
"reference": reference,
"procedure": procedure,
"movement": movement,
"username": username,
"system": system
}

View File

@@ -0,0 +1,162 @@
"""
Audit Log Service
"""
from datetime import datetime
import pytz
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from ..models import AuditLog
from .core import AuditMapper, ReferenceGenerator
from core.security import verify_token # keep if needed or simpler just remove if unused
# We don't need security import here anymore as context is passed explicitly or handled by events
class AuditService:
@staticmethod
def create_audit_log(
db: Session,
reference: str,
procedure: str,
movement: str,
username: str,
system: str = "SCAF",
# Extra context
table_name: Optional[str] = None,
record_id: Optional[str] = None,
operation_type: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
changed_fields: Optional[List[str]] = None,
# HTTP Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
endpoint: Optional[str] = None,
request_method: Optional[str] = None,
session_id: Optional[str] = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
) -> AuditLog:
"""
Low-level creation of an Audit Log entry
"""
# Timezone handling set to Mexico City as requested implicitly by legacy format example
tz = pytz.timezone('America/Mexico_City')
now = datetime.now(tz)
log = AuditLog(
reference=reference,
procedure=procedure,
movement=movement,
username=username,
date=now.date(),
time=now.time(),
timestamp=now,
system=system,
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=old_values,
new_values=new_values,
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
endpoint=endpoint,
request_method=request_method,
session_id=session_id,
company_id=company_id,
tenant_id=tenant_id
)
db.add(log)
db.commit()
db.refresh(log)
return log
@staticmethod
def log_crud_operation(
db: Session,
table_name: str,
operation_type: str,
record_data: Dict[str, Any],
username: str,
record_id: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
# Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
company_id: Optional[int] = None
):
"""
High-level wrapper to log CRUD operations automatically mapping to Legacy format
"""
# 1. Map to Legacy Base Format
legacy_data = AuditMapper.map_to_legacy_format(
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
username=username
)
# 2. Refine Reference based on specific table logic
reference = legacy_data["reference"]
if table_name == "invoice_header":
generated_ref = ReferenceGenerator.generate_invoice_reference(record_data)
# Use generated ref only if meaningful, else keep default
if generated_ref != "FUSE0-000-00":
reference = generated_ref
elif table_name == "pedimentos":
reference = ReferenceGenerator.generate_pedimento_reference(record_data)
elif table_name == "clients_and_providers":
reference = record_data.get("rfc") or reference
elif table_name == "parts":
reference = record_data.get("part_number") or reference
elif table_name == "companies":
reference = record_data.get("rfc") or reference
# 3. Detect Changed Fields (for Update)
changed_fields = None
if operation_type == "UPDATE" and old_values and new_values:
changed_fields = [
k for k in new_values.keys()
if old_values.get(k) != new_values.get(k)
]
# 4. Create Log
return AuditService.create_audit_log(
db=db,
reference=reference,
procedure=legacy_data["procedure"],
movement=legacy_data["movement"],
username=username,
system=legacy_data["system"],
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=old_values,
new_values=new_values,
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id
)
@staticmethod
def log_login(db: Session, username: str, ip_address: str = None):
return AuditService.create_audit_log(
db=db,
reference="LOGIN",
procedure="SYSTEM SCAF",
movement="SYSTEM LOGIN",
username=username,
operation_type="LOGIN",
ip_address=ip_address
)