Olympus/backend/hermes/app/services/audit_service.py
2026-07-05 11:54:23 +02:00

225 lines
9.5 KiB
Python

import logging
from typing import Any
from fastapi.encoders import jsonable_encoder
from sqlalchemy import inspect
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.audit import AuditLog
from app.models.user import User
from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
seen = seen or set()
if value is None or isinstance(value, str | int | float | bool):
return value
value_id = id(value)
if value_id in seen:
return None
if isinstance(value, list | tuple | set):
seen.add(value_id)
return [to_audit_data(item, seen) for item in value]
if isinstance(value, dict):
seen.add(value_id)
return {key: to_audit_data(item, seen) for key, item in value.items()}
try:
state = inspect(value)
except NoInspectionAvailable:
return jsonable_encoder(value)
seen.add(value_id)
data: dict[str, Any] = {}
for column in state.mapper.column_attrs:
data[column.key] = getattr(value, column.key)
for relationship in state.mapper.relationships:
if relationship.key in state.unloaded:
continue
related_value = getattr(value, relationship.key)
data[relationship.key] = to_audit_data(related_value, seen)
return data
def sanitize(value: Any) -> Any:
encoded = jsonable_encoder(to_audit_data(value))
if isinstance(encoded, dict):
return {
key: "***" if key in SENSITIVE_KEYS else sanitize(item)
for key, item in encoded.items()
}
if isinstance(encoded, list):
return [sanitize(item) for item in encoded]
return encoded
def request_ip(request: Request | None) -> str:
if request is None:
return ""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else ""
def write_audit_log(
db: Session,
*,
action: str,
entity_type: str,
entity_id: int | None = None,
entity_label: str = "",
actor: User | None = None,
actor_user_id: int | None = None,
actor_username: str = "",
request: Request | None = None,
before_data: Any = None,
after_data: Any = None,
metadata: Any = None,
) -> None:
try:
AuditRepository.create(
db,
actor_user_id=actor.id if actor is not None else actor_user_id,
actor_username=actor.username if actor is not None else actor_username,
action=action,
entity_type=entity_type,
entity_id=entity_id,
entity_label=entity_label,
ip_address=request_ip(request),
user_agent=request.headers.get("user-agent", "") if request is not None else "",
before_data=sanitize(before_data),
after_data=sanitize(after_data),
metadata=sanitize(metadata),
)
except Exception:
db.rollback()
logger.exception("audit.write_failed", extra={"action": action, "entity_type": entity_type})
def action_title(action: str) -> str:
labels = {
"auth.login.success": "Login erfolgreich",
"auth.login.failed": "Login fehlgeschlagen",
"auth.logout": "Logout",
"users.create": "Benutzer erstellt",
"users.update": "Benutzer bearbeitet",
"users.delete": "Benutzer gelöscht",
"users.password_update": "Passwort geändert",
"roles.create": "Rolle erstellt",
"roles.update": "Rolle bearbeitet",
"roles.delete": "Rolle gelöscht",
"roles.permissions_update": "Rollenberechtigungen geändert",
"customers.create": "Kunde erstellt",
"customers.update": "Kunde bearbeitet",
"customers.delete": "Kunde gelöscht",
"customers.import": "Kunden importiert",
"customer_contacts.create": "Ansprechpartner erstellt",
"customer_contacts.update": "Ansprechpartner bearbeitet",
"customer_contacts.delete": "Ansprechpartner gelöscht",
"knowledge.manufacturers.create": "Hersteller erstellt",
"knowledge.manufacturers.update": "Hersteller bearbeitet",
"knowledge.manufacturers.delete": "Hersteller gelöscht",
"knowledge.devices.create": "Gerät erstellt",
"knowledge.devices.update": "Gerät bearbeitet",
"knowledge.devices.delete": "Gerät gelöscht",
"knowledge.documents.create": "Dokument erstellt",
"knowledge.documents.upload": "Dokument hochgeladen",
"knowledge.documents.update": "Dokument bearbeitet",
"knowledge.documents.delete": "Dokument gelöscht",
"knowledge.notes.create": "Notiz erstellt",
"knowledge.notes.update": "Notiz bearbeitet",
"knowledge.notes.delete": "Notiz gelöscht",
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
"knowledge.manufacturers.create": "Hersteller erstellt",
"knowledge.manufacturers.update": "Hersteller bearbeitet",
"knowledge.manufacturers.delete": "Hersteller gelöscht",
"knowledge.devices.create": "Gerät erstellt",
"knowledge.devices.update": "Gerät bearbeitet",
"knowledge.devices.delete": "Gerät gelöscht",
"knowledge.documents.create": "Dokument erstellt",
"knowledge.documents.upload": "Dokument hochgeladen",
"knowledge.documents.update": "Dokument bearbeitet",
"knowledge.documents.delete": "Dokument gelöscht",
"knowledge.notes.create": "Wissensnotiz erstellt",
"knowledge.notes.update": "Wissensnotiz bearbeitet",
"knowledge.notes.delete": "Wissensnotiz gelöscht",
"repairs.create": "Reparatur erstellt",
"repairs.update": "Reparatur bearbeitet",
"repairs.status_update": "Reparaturstatus geändert",
"repairs.cancel": "Reparatur storniert",
"repairs.intake_processed": "Reparatur-Intake verarbeitet",
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
"repairs.public_link.create": "Reparatur-Statuslink erstellt",
"repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert",
"repairs.public_link.auto_create": "Reparatur-Statuslink automatisch erstellt",
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
"repairs.status_mail.sent": "Statusmail versendet",
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
"repairs.documents.upload": "Reparaturdokument hochgeladen",
"repairs.documents.update": "Reparaturdokument geändert",
"repairs.documents.delete": "Reparaturdokument gelöscht",
"repair_estimates.create": "Kostenvoranschlag erstellt",
"repair_estimates.update": "Kostenvoranschlag geändert",
"repair_estimates.send": "Kostenvoranschlag gesendet",
"repair_estimates.approve": "Kostenvoranschlag freigegeben",
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
"inventory.items.create": "Lagerartikel erstellt",
"inventory.items.update": "Lagerartikel geändert",
"inventory.items.deactivate": "Lagerartikel deaktiviert",
"inventory.items.delete": "Lagerartikel gelöscht",
"inventory.stock.adjust": "Bestand angepasst",
"inventory.stock.reserve": "Bestand reserviert",
"inventory.stock.release": "Reservierung aufgehoben",
"inventory.stock.consume": "Bestand verbraucht",
"inventory.estimate.reserve": "KV reserviert Lagerbestand",
"inventory.estimate.release": "KV-Reservierung aufgehoben",
"inventory.estimate.consume": "Reservierung verbraucht",
"inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben",
"inventory.stock.low": "Lagerbestand knapp",
"inventory.categories.create": "Lagerkategorie erstellt",
"inventory.categories.update": "Lagerkategorie geändert",
"inventory.categories.delete": "Lagerkategorie gelöscht",
"inventory.locations.create": "Lagerort erstellt",
"inventory.locations.update": "Lagerort geändert",
"inventory.locations.delete": "Lagerort gelöscht",
"inventory.suppliers.create": "Lieferant erstellt",
"inventory.suppliers.update": "Lieferant geändert",
"inventory.suppliers.delete": "Lieferant gelöscht",
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
}
return labels.get(action, action)
def audit_to_activity(audit_log: AuditLog) -> dict[str, Any]:
title = action_title(audit_log.action)
actor = audit_log.actor_username or "System"
label = audit_log.entity_label or audit_log.entity_type
return {
"id": audit_log.id,
"title": title,
"description": f"{actor} · {label}",
"actor": actor,
"entity_type": audit_log.entity_type,
"entity_id": audit_log.entity_id,
"created_at": audit_log.created_at,
}