Files
leocrm/app/routes/audit.py
T

180 lines
6.3 KiB
Python

"""Audit log routes — admin-only, paginated, filterable, export, retention."""
from __future__ import annotations
import csv
import io
import json
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.models.audit import AuditLog
router = APIRouter(prefix="/api/v1/audit-log", tags=["audit"])
def _audit_to_dict(a: AuditLog) -> dict:
"""Serialize an AuditLog ORM object to dict."""
return {
"id": str(a.id),
"user_id": str(a.user_id) if a.user_id else None,
"action": a.action,
"entity_type": a.entity_type,
"entity_id": str(a.entity_id) if a.entity_id else None,
"changes": a.changes,
"timestamp": a.timestamp.isoformat() if a.timestamp else None,
"tenant_id": str(a.tenant_id),
}
@router.get("")
async def list_audit_logs(
entity_type: str | None = Query(None, description="Filter by entity type"),
user_id: str | None = Query(None, description="Filter by user ID"),
action: str | None = Query(None, description="Filter by action (create/update/delete/login)"),
date_from: str | None = Query(None, description="ISO date range start (inclusive)"),
date_to: str | None = Query(None, description="ISO date range end (inclusive)"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("audit:read")),
):
"""List audit log entries with filtering and pagination. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
q = select(AuditLog).where(
AuditLog.tenant_id == tenant_id,
AuditLog.deleted_at.is_(None),
)
if entity_type:
q = q.where(AuditLog.entity_type == entity_type)
if user_id:
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
q = q.where(AuditLog.user_id == uid)
if action:
q = q.where(AuditLog.action == action)
if date_from:
try:
dt_from = datetime.fromisoformat(date_from)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid date_from format", "code": "invalid_date"}) from None
q = q.where(AuditLog.timestamp >= dt_from)
if date_to:
try:
dt_to = datetime.fromisoformat(date_to)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid date_to format", "code": "invalid_date"}) from None
q = q.where(AuditLog.timestamp <= dt_to)
# Count total
count_q = select(func.count()).select_from(q.subquery())
count_result = await db.execute(count_q)
total = count_result.scalar() or 0
# Apply pagination
offset = (page - 1) * page_size
q = q.order_by(AuditLog.timestamp.desc()).offset(offset).limit(page_size)
result = await db.execute(q)
entries = result.scalars().all()
return {
"items": [_audit_to_dict(a) for a in entries],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
}
@router.get("/export")
async def export_audit_logs(
format: str = Query("csv", description="Export format: csv or json"),
entity_type: str | None = Query(None),
action: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("audit:read")),
):
"""Export audit log entries as CSV or JSON. Admin only."""
tenant_id = uuid.UUID(current_user["tenant_id"])
q = select(AuditLog).where(
AuditLog.tenant_id == tenant_id,
AuditLog.deleted_at.is_(None),
)
if entity_type:
q = q.where(AuditLog.entity_type == entity_type)
if action:
q = q.where(AuditLog.action == action)
if date_from:
q = q.where(AuditLog.timestamp >= datetime.fromisoformat(date_from))
if date_to:
q = q.where(AuditLog.timestamp <= datetime.fromisoformat(date_to))
q = q.order_by(AuditLog.timestamp.desc()).limit(10000)
result = await db.execute(q)
entries = result.scalars().all()
if format == "json":
content = json.dumps([_audit_to_dict(a) for a in entries], indent=2, default=str)
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": "attachment; filename=audit_log_export.json"},
)
# CSV export
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "timestamp", "user_id", "action", "entity_type", "entity_id", "tenant_id"])
for a in entries:
writer.writerow([
str(a.id), a.timestamp.isoformat() if a.timestamp else "",
str(a.user_id) if a.user_id else "",
a.action, a.entity_type,
str(a.entity_id) if a.entity_id else "",
str(a.tenant_id),
])
content = output.getvalue()
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit_log_export.csv"},
)
@router.delete("/retention")
async def audit_retention_cleanup(
retention_days: int = Query(365, ge=1, le=3650, description="Delete entries older than N days"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("audit:read")),
):
"""Delete audit log entries older than retention_days. Admin only.
Default retention: 365 days.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
q = delete(AuditLog).where(
AuditLog.tenant_id == tenant_id,
AuditLog.timestamp < cutoff,
)
result = await db.execute(q)
await db.commit()
return {"deleted": result.rowcount, "retention_days": retention_days, "cutoff": cutoff.isoformat()}