Files
leocrm/app/routes/audit.py
T
Agent Zero b2f75495de fix(security): F20 (Astra P1) — pauschaler Boot-GRANT entfernt, DELETE-Rechte als Migration 0145 festgeschrieben
Vorher: prestart.sh fuehrte bei JEDEM Container-Start
GRANT DELETE ON ALL TABLES fuer crm_api/crm_auth/crm_worker aus — und
hob damit Migration 0100 auf, die DELETE auf 12 sensiblen Tabellen
(audit_log, api_tokens, password_reset_tokens, tenants, ...)
gezielt entzogen hatte. Der Blanket-Grant war ein BUG-030-Workaround
(User-DELETE 500), der den Schutz seit jedem Start zerstoerte.

Fix:
- Migration 0145 (0145_delete_grants_converged): deterministischer
  Sollzustand — REVOKE DELETE auf geschuetzten Tabellen von beiden
  Runtime-Rollen (audit_log, api_tokens, password_reset_tokens,
  plugin_allowlist, plugin_migrations, tenants,
  tenant_plugin_activation); GRANT DELETE auf legitime Runtime-Loeschungen
  (users, user_tenants, sessions, plugins, notification_types) NUR fuer
  crm_api; crm_worker erhaelt kein DELETE auf geschuetzten Tabellen.
- prestart.sh: Blanket-GRANT-Block entfernt, durch dokumentierenden
  Verweis auf 0145 ersetzt.
- audit.py Retention-Route: Delete laeuft ueber Migrations-Session-Factory
  (Table-Owner) statt Request-DB — Runtime-Rollen koennen Auditdaten
  schreiben aber NIEMALS loeschen (Astra-Abnahme). Gleiches Muster wie
  Plugin-Uninstall.

Abnahme (Astra): API und Worker koennen Auditdaten schreiben, aber nicht
loeschen — erfuellt (audit_log DELETE von crm_api/crm_worker entzogen,
Retention als dokumentierte Wartungsoperation ueber Owner-Session).

Verifikation: Migration-Syntax OK, ruff clean, alembic heads = genau 0145,
prestart bash -n OK, test_audit_architecture_fixes + test_user_service
30/30 (Logout-Session-Delete, User-DELETE, Audit-Pfade alle intakt).

Bekannte Grenze (ehrlich): Kuenftige Plugin-Tabellen brauchen ihre
DELETE-Rechte in der jeweiligen Migration statt im Boot-Skript —
sync_plugin_schema.py vergibt KEINE GRANTs (verifiziert), deshalb ist
das Default-Privilege-Problem in S2 (F18 Schema-Verantwortung)
adressiert.
2026-09-18 08:46:27 +02:00

189 lines
6.8 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.
F20 (Astra): runtime roles (crm_api/crm_worker) must NOT be able to
delete audit data. The delete runs via the migration session factory
(table owner) instead of the request ``db`` — a documented maintenance
operation, same pattern as plugin uninstall.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
from app.core.db import get_migration_session_factory
factory = get_migration_session_factory()
async with factory() as mig_db:
q = delete(AuditLog).where(
AuditLog.tenant_id == tenant_id,
AuditLog.timestamp < cutoff,
)
result = await mig_db.execute(q)
await mig_db.commit()
return {"deleted": result.rowcount, "retention_days": retention_days, "cutoff": cutoff.isoformat()}