463 lines
15 KiB
Python
463 lines
15 KiB
Python
"""
|
|
Audit Log Router
|
|
"""
|
|
from datetime import date
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import or_, desc
|
|
|
|
from core.database import get_core_db, set_rls_context
|
|
from core.security import get_current_user, validate_access_to_resource
|
|
from core.storage_s3 import get_object_bytes, list_objects_tree, should_ensure_s3_bucket
|
|
from .models import AuditLog
|
|
from .schemas import (
|
|
AuditFileBreadcrumb,
|
|
AuditFileBrowserResponse,
|
|
AuditFileFolderItem,
|
|
AuditFileObjectItem,
|
|
AuditLogDetailResponse,
|
|
AuditLogListResponse,
|
|
)
|
|
from api.v1.modules.a76.general_catalogs.company.models import Company
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _audit_scope_tenant_id(db: Session, company_id: int) -> int:
|
|
"""Tenant_id de la fila ``Company`` para filtrar ``audit_logs`` (alineado con lo persistido)."""
|
|
row = (
|
|
db.query(Company.tenant_id)
|
|
.filter(Company.id == company_id, Company.deleted_at.is_(None))
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Company not found")
|
|
return int(row[0])
|
|
|
|
|
|
_SEGMENT_LABELS = {
|
|
"tenants": "Espacio",
|
|
"companies": "Companias",
|
|
"users": "Usuarios",
|
|
"imports": "Importaciones",
|
|
"csv": "Archivos CSV",
|
|
"branding": "Logotipos",
|
|
"certificates": "Certificados",
|
|
"customs_brokers": "Agentes aduanales",
|
|
"keys": "Llaves",
|
|
"cove": "COVE",
|
|
"doda": "DODA",
|
|
"system": "Sistema",
|
|
"help": "Ayuda",
|
|
}
|
|
|
|
|
|
def _normalize_relative_path(raw: Optional[str]) -> str:
|
|
if not raw:
|
|
return ""
|
|
val = raw.strip().strip("/")
|
|
if not val:
|
|
return ""
|
|
if ".." in val or "\\" in val:
|
|
raise HTTPException(status_code=400, detail="Invalid path")
|
|
parts = [p for p in val.split("/") if p]
|
|
for part in parts:
|
|
if part in (".", ".."):
|
|
raise HTTPException(status_code=400, detail="Invalid path segment")
|
|
return "/".join(parts)
|
|
|
|
|
|
def _tenant_prefix(tenant_id: int) -> str:
|
|
return f"tenants/{tenant_id}/"
|
|
|
|
|
|
def _relative_from_tenant_prefix(key: str, tenant_prefix: str) -> str:
|
|
if not key.startswith(tenant_prefix):
|
|
raise HTTPException(status_code=403, detail="Access denied to object key")
|
|
return key[len(tenant_prefix) :].strip("/")
|
|
|
|
|
|
def _companies_map(db: Session, tenant_id: int) -> Dict[str, str]:
|
|
rows = (
|
|
db.query(Company.id, Company.name)
|
|
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
|
|
.all()
|
|
)
|
|
out: Dict[str, str] = {}
|
|
for company_id, company_name in rows:
|
|
if company_id is None:
|
|
continue
|
|
safe_name = (company_name or "").strip()
|
|
out[str(company_id)] = safe_name or "Compania"
|
|
return out
|
|
|
|
|
|
def _display_segment(
|
|
part: str,
|
|
prev_part: Optional[str],
|
|
company_names: Dict[str, str],
|
|
current_user_id: Optional[str] = None,
|
|
current_user_label: Optional[str] = None,
|
|
) -> str:
|
|
if prev_part == "companies":
|
|
return company_names.get(part, "Compania")
|
|
if prev_part == "users":
|
|
# Para carpetas de usuarios, mostrar un nombre amigable:
|
|
# - Si es el propio usuario actual, usar preferred_username/email/nombre.
|
|
# - Para otros IDs (UUIDs) mostrar un label genérico.
|
|
if current_user_id and part == str(current_user_id):
|
|
return (current_user_label or "").strip() or "Usuario"
|
|
return "Usuario"
|
|
if part in _SEGMENT_LABELS:
|
|
return _SEGMENT_LABELS[part]
|
|
# Evita exponer IDs puros en UI.
|
|
if part.isdigit():
|
|
return "Elemento"
|
|
return part.replace("_", " ").strip().title() or "Elemento"
|
|
|
|
|
|
def _display_path(
|
|
rel_path: str,
|
|
company_names: Dict[str, str],
|
|
current_user_id: Optional[str] = None,
|
|
current_user_label: Optional[str] = None,
|
|
) -> str:
|
|
if not rel_path:
|
|
return "Raiz de archivos"
|
|
parts = [p for p in rel_path.split("/") if p]
|
|
labels: List[str] = []
|
|
prev: Optional[str] = None
|
|
for part in parts:
|
|
labels.append(
|
|
_display_segment(
|
|
part,
|
|
prev,
|
|
company_names,
|
|
current_user_id=current_user_id,
|
|
current_user_label=current_user_label,
|
|
)
|
|
)
|
|
prev = part
|
|
return " / ".join(labels)
|
|
|
|
|
|
def _display_file_name(filename: str) -> str:
|
|
stem, dot, ext = filename.rpartition(".")
|
|
if not dot:
|
|
stem = filename
|
|
ext = ""
|
|
if stem.isdigit():
|
|
return f"Archivo{f'.{ext}' if ext else ''}"
|
|
return filename
|
|
|
|
|
|
def _build_breadcrumbs(
|
|
rel_path: str,
|
|
company_names: Dict[str, str],
|
|
current_user_id: Optional[str] = None,
|
|
current_user_label: Optional[str] = None,
|
|
) -> List[AuditFileBreadcrumb]:
|
|
breadcrumbs: List[AuditFileBreadcrumb] = [
|
|
AuditFileBreadcrumb(path="", display_name="Raiz de archivos")
|
|
]
|
|
if not rel_path:
|
|
return breadcrumbs
|
|
parts = [p for p in rel_path.split("/") if p]
|
|
prev: Optional[str] = None
|
|
acc: List[str] = []
|
|
for part in parts:
|
|
acc.append(part)
|
|
breadcrumbs.append(
|
|
AuditFileBreadcrumb(
|
|
path="/".join(acc),
|
|
display_name=_display_segment(
|
|
part,
|
|
prev,
|
|
company_names,
|
|
current_user_id=current_user_id,
|
|
current_user_label=current_user_label,
|
|
),
|
|
)
|
|
)
|
|
prev = part
|
|
return breadcrumbs
|
|
|
|
@router.get("/bitacora", response_model=AuditLogListResponse)
|
|
async def get_bitacora(
|
|
company_id: int = Query(..., description="Company ID"),
|
|
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),
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Bitácora por compañía. Requiere permiso ``audit_logs.view``.
|
|
"""
|
|
validate_access_to_resource(
|
|
db,
|
|
company_id,
|
|
current_user,
|
|
required_permissions=["audit_logs.view"],
|
|
)
|
|
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
|
|
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
|
|
|
|
query = db.query(AuditLog).filter(
|
|
AuditLog.company_id == company_id,
|
|
AuditLog.tenant_id == scope_tenant_id,
|
|
)
|
|
|
|
# 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(
|
|
company_id: int = Query(..., description="Company ID"),
|
|
db: Session = Depends(get_core_db),
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Lista de procedimientos para filtros (alcance compañía). Requiere ``audit_logs.view``.
|
|
"""
|
|
validate_access_to_resource(
|
|
db,
|
|
company_id,
|
|
current_user,
|
|
required_permissions=["audit_logs.view"],
|
|
)
|
|
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
|
|
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
|
|
|
|
results = (
|
|
db.query(AuditLog.procedure)
|
|
.filter(
|
|
AuditLog.company_id == company_id,
|
|
AuditLog.tenant_id == scope_tenant_id,
|
|
)
|
|
.distinct()
|
|
.order_by(AuditLog.procedure)
|
|
.all()
|
|
)
|
|
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,
|
|
company_id: int = Query(..., description="Company ID"),
|
|
db: Session = Depends(get_core_db),
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Detalle de un registro de bitácora. Requiere ``audit_logs.view``.
|
|
"""
|
|
validate_access_to_resource(
|
|
db,
|
|
company_id,
|
|
current_user,
|
|
required_permissions=["audit_logs.view"],
|
|
)
|
|
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
|
|
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
|
|
|
|
log = (
|
|
db.query(AuditLog)
|
|
.filter(
|
|
AuditLog.spec_id == spec_id,
|
|
AuditLog.company_id == company_id,
|
|
AuditLog.tenant_id == scope_tenant_id,
|
|
)
|
|
.first()
|
|
)
|
|
if not log:
|
|
raise HTTPException(status_code=404, detail="Log entry not found")
|
|
return log
|
|
|
|
|
|
@router.get("/files", response_model=AuditFileBrowserResponse)
|
|
async def list_tenant_files(
|
|
company_id: int = Query(..., description="Company ID"),
|
|
path: Optional[str] = Query(default="", description="Ruta relativa de navegación."),
|
|
continuation_token: Optional[str] = Query(default=None),
|
|
max_keys: int = Query(default=100, ge=1, le=500),
|
|
db: Session = Depends(get_core_db),
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Explorador de archivos de solo lectura para Auditoría.
|
|
Requiere permiso ``audit_logs.view``; el prefijo S3 sigue al tenant de la compañía.
|
|
"""
|
|
if not should_ensure_s3_bucket():
|
|
raise HTTPException(status_code=400, detail="S3 storage is disabled")
|
|
|
|
validate_access_to_resource(
|
|
db,
|
|
company_id,
|
|
current_user,
|
|
required_permissions=["audit_logs.view"],
|
|
)
|
|
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
|
|
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
|
|
tenant_prefix = _tenant_prefix(scope_tenant_id)
|
|
rel_path = _normalize_relative_path(path)
|
|
list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix
|
|
|
|
# Datos del usuario actual para etiquetas amigables bajo /users/{id}/...
|
|
current_user_id = str(current_user.get("sub") or "")
|
|
current_user_label = (
|
|
(current_user.get("preferred_username") or "").strip()
|
|
or (current_user.get("name") or "").strip()
|
|
or (current_user.get("email") or "").strip()
|
|
or "Usuario"
|
|
)
|
|
|
|
data = list_objects_tree(
|
|
prefix=list_prefix,
|
|
delimiter="/",
|
|
max_keys=max_keys,
|
|
continuation_token=continuation_token,
|
|
)
|
|
|
|
company_names = _companies_map(db, scope_tenant_id)
|
|
folders: List[AuditFileFolderItem] = []
|
|
for prefix in data.get("prefixes", []):
|
|
rel = _relative_from_tenant_prefix(prefix, tenant_prefix)
|
|
folders.append(
|
|
AuditFileFolderItem(
|
|
path=rel,
|
|
# Para el gestor de archivos mostramos el nombre amigable del último segmento
|
|
# (empresa, usuario actual, etc.), no el ID bruto.
|
|
display_name=_display_path(
|
|
rel,
|
|
company_names,
|
|
current_user_id=current_user_id,
|
|
current_user_label=current_user_label,
|
|
).split(" / ")[-1],
|
|
)
|
|
)
|
|
|
|
files: List[AuditFileObjectItem] = []
|
|
for obj in data.get("objects", []):
|
|
key = obj.get("key")
|
|
if not key:
|
|
continue
|
|
rel = _relative_from_tenant_prefix(key, tenant_prefix)
|
|
name = rel.rsplit("/", 1)[-1]
|
|
files.append(
|
|
AuditFileObjectItem(
|
|
path=rel,
|
|
display_name=_display_file_name(name),
|
|
size=int(obj.get("size", 0) or 0),
|
|
last_modified=obj.get("last_modified"),
|
|
)
|
|
)
|
|
|
|
return AuditFileBrowserResponse(
|
|
current_path=rel_path,
|
|
display_path=_display_path(
|
|
rel_path,
|
|
company_names,
|
|
current_user_id=current_user_id,
|
|
current_user_label=current_user_label,
|
|
),
|
|
breadcrumbs=_build_breadcrumbs(
|
|
rel_path,
|
|
company_names,
|
|
current_user_id=current_user_id,
|
|
current_user_label=current_user_label,
|
|
),
|
|
folders=sorted(folders, key=lambda x: x.display_name.lower()),
|
|
files=sorted(files, key=lambda x: x.display_name.lower()),
|
|
next_token=data.get("next_continuation_token"),
|
|
)
|
|
|
|
|
|
@router.get("/files/download")
|
|
async def download_tenant_file(
|
|
company_id: int = Query(..., description="Company ID"),
|
|
path: str = Query(..., description="Ruta relativa del archivo a descargar."),
|
|
db: Session = Depends(get_core_db),
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Descarga segura (backend streaming) de archivos autorizados.
|
|
Requiere ``audit_logs.view``.
|
|
"""
|
|
if not should_ensure_s3_bucket():
|
|
raise HTTPException(status_code=400, detail="S3 storage is disabled")
|
|
|
|
validate_access_to_resource(
|
|
db,
|
|
company_id,
|
|
current_user,
|
|
required_permissions=["audit_logs.view"],
|
|
)
|
|
scope_tenant_id = _audit_scope_tenant_id(db, company_id)
|
|
set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id)
|
|
tenant_prefix = _tenant_prefix(scope_tenant_id)
|
|
rel_path = _normalize_relative_path(path)
|
|
if not rel_path or rel_path.endswith("/"):
|
|
raise HTTPException(status_code=400, detail="A file path is required")
|
|
|
|
object_key = f"{tenant_prefix}{rel_path}"
|
|
if not object_key.startswith(tenant_prefix):
|
|
raise HTTPException(status_code=403, detail="Access denied to object key")
|
|
|
|
try:
|
|
body = get_object_bytes(object_key)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=404, detail=f"File not found: {e}") from e
|
|
|
|
filename = rel_path.rsplit("/", 1)[-1]
|
|
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
|
return StreamingResponse(
|
|
iter([body]),
|
|
media_type="application/octet-stream",
|
|
headers=headers,
|
|
)
|