feat(platform): add audit logs and activity feed

This commit is contained in:
Schubert Ferenc 2026-07-03 00:04:44 +02:00
parent 92cb8d1286
commit c816e9869d
34 changed files with 1592 additions and 43 deletions

View file

@ -0,0 +1,86 @@
"""add audit logs
Revision ID: a5e4b7c9d012
Revises: 9c1d8a2f6b44
Create Date: 2026-07-02 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "a5e4b7c9d012"
down_revision: Union[str, Sequence[str], None] = "9c1d8a2f6b44"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("actor_username", sa.String(length=120), server_default="", nullable=False),
sa.Column("action", sa.String(length=120), nullable=False),
sa.Column("entity_type", sa.String(length=80), nullable=False),
sa.Column("entity_id", sa.Integer(), nullable=True),
sa.Column("entity_label", sa.String(length=255), server_default="", nullable=False),
sa.Column("ip_address", sa.String(length=80), server_default="", nullable=False),
sa.Column("user_agent", sa.Text(), server_default="", nullable=False),
sa.Column("before_data", sa.JSON(), nullable=True),
sa.Column("after_data", sa.JSON(), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_audit_logs_action"), "audit_logs", ["action"], unique=False)
op.create_index(op.f("ix_audit_logs_actor_user_id"), "audit_logs", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_audit_logs_entity_id"), "audit_logs", ["entity_id"], unique=False)
op.create_index(op.f("ix_audit_logs_entity_type"), "audit_logs", ["entity_type"], unique=False)
op.create_index(op.f("ix_audit_logs_created_at"), "audit_logs", ["created_at"], unique=False)
op.execute(
"""
INSERT INTO permissions (name, display_name, description, module)
SELECT 'audit_logs.read', 'Audit Logs lesen', 'Audit Logs anzeigen', 'audit_logs'
WHERE NOT EXISTS (
SELECT 1 FROM permissions WHERE name = 'audit_logs.read'
)
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name = 'audit_logs.read'
WHERE roles.name = 'administrator'
AND NOT EXISTS (
SELECT 1
FROM role_permissions
WHERE role_permissions.role_id = roles.id
AND role_permissions.permission_id = permissions.id
)
"""
)
def downgrade() -> None:
op.execute(
"""
DELETE FROM role_permissions
USING permissions
WHERE role_permissions.permission_id = permissions.id
AND permissions.name = 'audit_logs.read'
"""
)
op.execute("DELETE FROM permissions WHERE name = 'audit_logs.read'")
op.drop_index(op.f("ix_audit_logs_created_at"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_entity_type"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_entity_id"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_actor_user_id"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_action"), table_name="audit_logs")
op.drop_table("audit_logs")

View file

@ -0,0 +1,80 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.audit_repository import AuditRepository
from app.schemas.api_response import ApiSuccess
from app.schemas.audit import ActivityFeedItem, AuditLogListResponse
from app.services.audit_service import audit_to_activity
router = APIRouter(tags=["Audit"])
@router.get("/audit-logs", response_model=ApiSuccess)
def get_audit_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=100),
entity_type: str | None = None,
actor_user_id: int | None = None,
action: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("audit_logs.read")),
):
items, total = AuditRepository.list(
db,
page=page,
page_size=page_size,
entity_type=entity_type,
actor_user_id=actor_user_id,
action=action,
date_from=date_from,
date_to=date_to,
)
return ApiSuccess(
data=AuditLogListResponse(
items=items,
total=total,
page=page,
page_size=page_size,
),
message="Audit Logs geladen",
)
def can_read_activity(action: str, permissions: set[str]) -> bool:
if action.startswith("users."):
return "users.read" in permissions
if action.startswith("customers.") or action.startswith("customer_contacts."):
return "customers.read" in permissions
if action.startswith("roles."):
return "roles.read" in permissions
if action.startswith("audit_logs."):
return "audit_logs.read" in permissions
if action.startswith("auth."):
return "audit_logs.read" in permissions
return False
@router.get("/activity-feed", response_model=ApiSuccess)
def get_activity_feed(
limit: int = Query(default=10, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard.read")),
):
permissions = get_user_permission_names(current_user)
activities: list[ActivityFeedItem] = []
for audit_log in AuditRepository.latest(db, limit=100):
if not can_read_activity(audit_log.action, permissions):
continue
activities.append(ActivityFeedItem(**audit_to_activity(audit_log)))
if len(activities) >= limit:
break
return ApiSuccess(data=activities, message="Aktivitäten geladen")

View file

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.security import (
create_access_token,
@ -12,6 +13,7 @@ from app.models.user import User
from app.repositories.user_repository import UserRepository
from app.schemas.rbac import CurrentUserResponse
from app.schemas.user import LoginRequest, LoginResponse
from app.services.audit_service import write_audit_log
router = APIRouter(
prefix="/auth",
@ -22,23 +24,50 @@ router = APIRouter(
@router.post("/login", response_model=LoginResponse)
def login(
login: LoginRequest,
request: Request,
db: Session = Depends(get_db),
):
user = UserRepository.get_by_username(db, login.username)
if not user:
write_audit_log(
db,
action="auth.login.failed",
entity_type="auth",
entity_label=login.username,
actor_username=login.username,
request=request,
)
raise HTTPException(
status_code=401,
detail="Ungültige Anmeldedaten",
)
if not verify_password(login.password, user.password_hash):
write_audit_log(
db,
action="auth.login.failed",
entity_type="auth",
entity_label=login.username,
actor_user_id=user.id,
actor_username=user.username,
request=request,
)
raise HTTPException(
status_code=401,
detail="Ungültige Anmeldedaten",
)
token = create_access_token(user.username)
write_audit_log(
db,
action="auth.login.success",
entity_type="auth",
entity_id=user.id,
entity_label=user.username,
actor=user,
request=request,
)
return {
"access_token": token,

View file

@ -2,6 +2,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
@ -16,6 +17,7 @@ from app.schemas.customer import (
CustomerResponse,
CustomerUpdate,
)
from app.services.audit_service import sanitize, write_audit_log
logger = logging.getLogger(__name__)
@ -94,42 +96,81 @@ def get_customer(
)
def create_customer(
customer: CustomerCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.create")),
):
ensure_unique_customer_number(db, customer.customer_number)
logger.info("customers.create", extra={"actor_user_id": current_user.id})
return CustomerRepository.create(db, customer)
created_customer = CustomerRepository.create(db, customer)
write_audit_log(
db,
action="customers.create",
entity_type="customers",
entity_id=created_customer.id,
entity_label=created_customer.company_name,
actor=current_user,
request=request,
after_data=created_customer,
)
return created_customer
@router.put("/{customer_id}", response_model=CustomerResponse)
def update_customer(
customer_id: int,
customer: CustomerUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")),
):
db_customer = get_customer_or_404(db, customer_id)
before_data = sanitize(db_customer)
ensure_unique_customer_number(db, customer.customer_number, customer_id)
logger.info(
"customers.update",
extra={"actor_user_id": current_user.id, "target_customer_id": customer_id},
)
return CustomerRepository.update(db, db_customer, customer)
updated_customer = CustomerRepository.update(db, db_customer, customer)
write_audit_log(
db,
action="customers.update",
entity_type="customers",
entity_id=updated_customer.id,
entity_label=updated_customer.company_name,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_customer,
)
return updated_customer
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_customer(
customer_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.delete")),
):
db_customer = get_customer_or_404(db, customer_id)
before_data = sanitize(db_customer)
entity_label = db_customer.company_name
logger.info(
"customers.delete",
extra={"actor_user_id": current_user.id, "target_customer_id": customer_id},
)
CustomerRepository.delete(db, db_customer)
write_audit_log(
db,
action="customers.delete",
entity_type="customers",
entity_id=customer_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -155,15 +196,28 @@ def get_customer_contacts(
def create_customer_contact(
customer_id: int,
contact: CustomerContactCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")),
):
get_customer_or_404(db, customer_id)
customer = get_customer_or_404(db, customer_id)
logger.info(
"customers.contacts.create",
extra={"actor_user_id": current_user.id, "target_customer_id": customer_id},
)
return CustomerRepository.create_contact(db, customer_id, contact)
created_contact = CustomerRepository.create_contact(db, customer_id, contact)
write_audit_log(
db,
action="customer_contacts.create",
entity_type="customer_contacts",
entity_id=created_contact.id,
entity_label=f"{created_contact.first_name} {created_contact.last_name}".strip(),
actor=current_user,
request=request,
after_data=created_contact,
metadata={"customer_id": customer_id, "customer_label": customer.company_name},
)
return created_contact
@router.put("/{customer_id}/contacts/{contact_id}", response_model=CustomerContactResponse)
@ -171,11 +225,13 @@ def update_customer_contact(
customer_id: int,
contact_id: int,
contact: CustomerContactUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")),
):
get_customer_or_404(db, customer_id)
customer = get_customer_or_404(db, customer_id)
db_contact = get_contact_or_404(db, customer_id, contact_id)
before_data = sanitize(db_contact)
logger.info(
"customers.contacts.update",
extra={
@ -184,18 +240,34 @@ def update_customer_contact(
"target_contact_id": contact_id,
},
)
return CustomerRepository.update_contact(db, db_contact, contact)
updated_contact = CustomerRepository.update_contact(db, db_contact, contact)
write_audit_log(
db,
action="customer_contacts.update",
entity_type="customer_contacts",
entity_id=updated_contact.id,
entity_label=f"{updated_contact.first_name} {updated_contact.last_name}".strip(),
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_contact,
metadata={"customer_id": customer_id, "customer_label": customer.company_name},
)
return updated_contact
@router.delete("/{customer_id}/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_customer_contact(
customer_id: int,
contact_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")),
):
get_customer_or_404(db, customer_id)
customer = get_customer_or_404(db, customer_id)
db_contact = get_contact_or_404(db, customer_id, contact_id)
before_data = sanitize(db_contact)
entity_label = f"{db_contact.first_name} {db_contact.last_name}".strip()
logger.info(
"customers.contacts.delete",
extra={
@ -205,4 +277,15 @@ def delete_customer_contact(
},
)
CustomerRepository.delete_contact(db, db_contact)
write_audit_log(
db,
action="customer_contacts.delete",
entity_type="customer_contacts",
entity_id=contact_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
metadata={"customer_id": customer_id, "customer_label": customer.company_name},
)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -3,12 +3,14 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.rbac_repository import RbacRepository
from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleResponse, RoleUpdate
from app.services.audit_service import sanitize, write_audit_log
logger = logging.getLogger(__name__)
@ -80,6 +82,7 @@ def get_role(
)
def create_role(
role: RoleCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.create")),
):
@ -87,17 +90,30 @@ def create_role(
ensure_permission_ids_exist(db, role.permission_ids)
logger.info("roles.create", extra={"actor_user_id": current_user.id})
return RbacRepository.create_role(db, role)
created_role = RbacRepository.create_role(db, role)
write_audit_log(
db,
action="roles.create",
entity_type="roles",
entity_id=created_role.id,
entity_label=created_role.name,
actor=current_user,
request=request,
after_data=created_role,
)
return created_role
@router.put("/{role_id}", response_model=RoleResponse)
def update_role(
role_id: int,
role: RoleUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.update")),
):
db_role = get_role_or_404(db, role_id)
before_data = sanitize(db_role)
ensure_unique_role_name(db, role.name, role_id)
ensure_permission_ids_exist(db, role.permission_ids)
@ -105,16 +121,31 @@ def update_role(
"roles.update",
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
)
return RbacRepository.update_role(db, db_role, role)
updated_role = RbacRepository.update_role(db, db_role, role)
write_audit_log(
db,
action="roles.update",
entity_type="roles",
entity_id=updated_role.id,
entity_label=updated_role.name,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_role,
)
return updated_role
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(
role_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.delete")),
):
db_role = get_role_or_404(db, role_id)
before_data = sanitize(db_role)
entity_label = db_role.name
if db_role.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -133,6 +164,16 @@ def delete_role(
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
)
RbacRepository.delete_role(db, db_role)
write_audit_log(
db,
action="roles.delete",
entity_type="roles",
entity_id=role_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -140,14 +181,28 @@ def delete_role(
def update_role_permissions(
role_id: int,
permission_update: RolePermissionUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.assign")),
):
db_role = get_role_or_404(db, role_id)
before_data = sanitize(db_role)
ensure_permission_ids_exist(db, permission_update.permission_ids)
logger.info(
"roles.permissions_update",
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
)
return RbacRepository.update_role_permissions(db, db_role, permission_update)
updated_role = RbacRepository.update_role_permissions(db, db_role, permission_update)
write_audit_log(
db,
action="roles.permissions_update",
entity_type="roles",
entity_id=updated_role.id,
entity_label=updated_role.name,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_role,
)
return updated_role

View file

@ -2,6 +2,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db
@ -9,6 +10,7 @@ from app.models.user import User
from app.repositories.rbac_repository import RbacRepository
from app.repositories.user_repository import UserRepository
from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate
from app.services.audit_service import sanitize, write_audit_log
logger = logging.getLogger(__name__)
@ -98,6 +100,7 @@ def get_user(
)
def create_user(
user: UserCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.create")),
):
@ -113,17 +116,30 @@ def create_user(
raise_conflict(conflict[0])
logger.info("users.create", extra={"actor_user_id": current_user.id})
return UserRepository.create(db, user)
created_user = UserRepository.create(db, user)
write_audit_log(
db,
action="users.create",
entity_type="users",
entity_id=created_user.id,
entity_label=created_user.username,
actor=current_user,
request=request,
after_data=created_user,
)
return created_user
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user: UserUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.update")),
):
db_user = get_user_or_404(db, user_id)
before_data = sanitize(db_user)
apply_role(db, user)
if user.role_id != db_user.role_id:
require_actor_permission(current_user, "roles.assign")
@ -141,28 +157,56 @@ def update_user(
"users.update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return UserRepository.update(db, db_user, user)
updated_user = UserRepository.update(db, db_user, user)
write_audit_log(
db,
action="users.update",
entity_type="users",
entity_id=updated_user.id,
entity_label=updated_user.username,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_user,
)
return updated_user
@router.put("/{user_id}/password", response_model=UserResponse)
def update_user_password(
user_id: int,
password_update: UserPasswordUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.password.update")),
):
db_user = get_user_or_404(db, user_id)
before_data = sanitize(db_user)
logger.info(
"users.password_update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return UserRepository.update_password(db, db_user, password_update)
updated_user = UserRepository.update_password(db, db_user, password_update)
write_audit_log(
db,
action="users.password_update",
entity_type="users",
entity_id=updated_user.id,
entity_label=updated_user.username,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_user,
metadata={"password_changed": True},
)
return updated_user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.delete")),
):
@ -173,10 +217,22 @@ def delete_user(
)
db_user = get_user_or_404(db, user_id)
before_data = sanitize(db_user)
entity_label = db_user.username
logger.info(
"users.delete",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
UserRepository.delete(db, db_user)
write_audit_log(
db,
action="users.delete",
entity_type="users",
entity_id=user_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -9,6 +9,7 @@ class Settings(BaseSettings):
app_version: str = "0.1.0"
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
log_level: str = "INFO"
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -0,0 +1,14 @@
import logging
import sys
from app.core.config import settings
def configure_logging() -> None:
level = getattr(logging, settings.log_level.upper(), logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
stream=sys.stdout,
force=True,
)

View file

@ -22,6 +22,7 @@ class Base(DeclarativeBase):
# <<< HIER IMPORTIEREN >>>
import app.models.rbac
import app.models.customer
import app.models.audit
import app.models.user

View file

@ -1,4 +1,5 @@
import logging
import time
from fastapi import FastAPI
from fastapi import HTTPException
@ -9,6 +10,7 @@ from starlette import status
from starlette.requests import Request
from app.api.auth import router as auth_router
from app.api.audit import router as audit_router
from app.api.customers import router as customers_router
from app.api.dashboard import router as dashboard_router
from app.api.permissions import router as permissions_router
@ -16,8 +18,11 @@ from app.api.roles import router as roles_router
from app.api.users import router as users_router
from app.db.database import SessionLocal
from app.db.health import check_database
from app.core.logging import configure_logging
from app.rbac.seed import seed_rbac
configure_logging()
app = FastAPI(
title="Hermes API",
version="0.1.0",
@ -25,6 +30,7 @@ app = FastAPI(
)
app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(users_router)
app.include_router(roles_router)
app.include_router(permissions_router)
@ -34,6 +40,34 @@ app.include_router(dashboard_router)
logger = logging.getLogger(__name__)
@app.middleware("http")
async def request_logging_middleware(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 2)
logger.info(
"request.completed",
extra={
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": duration_ms,
},
)
return response
def error_code_for_status(status_code: int) -> str:
return {
400: "BAD_REQUEST",
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
409: "CONFLICT",
422: "VALIDATION_ERROR",
}.get(status_code, "HTTP_ERROR")
@app.on_event("startup")
def startup_seed_rbac():
db = SessionLocal()
@ -55,7 +89,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
content={
"success": False,
"message": str(exc.detail),
"error_code": error_code_for_status(exc.status_code),
"details": [],
"detail": exc.detail,
},
headers=exc.headers,
)
@ -68,7 +108,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())},
content={
"success": False,
"message": "Validierung fehlgeschlagen",
"error_code": "VALIDATION_ERROR",
"details": jsonable_encoder(exc.errors()),
"detail": jsonable_encoder(exc.errors()),
},
)
@ -80,7 +126,13 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Interner Serverfehler"},
content={
"success": False,
"message": "Interner Serverfehler",
"error_code": "INTERNAL_SERVER_ERROR",
"details": [],
"detail": "Interner Serverfehler",
},
)

View file

@ -0,0 +1,28 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
actor_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
actor_username: Mapped[str] = mapped_column(String(120), default="", server_default="")
action: Mapped[str] = mapped_column(String(120), index=True)
entity_type: Mapped[str] = mapped_column(String(80), index=True)
entity_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
entity_label: Mapped[str] = mapped_column(String(255), default="", server_default="")
ip_address: Mapped[str] = mapped_column(String(80), default="", server_default="")
user_agent: Mapped[str] = mapped_column(Text, default="", server_default="")
before_data: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
after_data: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
metadata_data: Mapped[dict | list | None] = mapped_column("metadata", JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -69,6 +69,7 @@ STANDARD_PERMISSIONS = [
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
]
ROLE_PERMISSION_NAMES = {

View file

@ -0,0 +1,104 @@
from datetime import datetime
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models.audit import AuditLog
class AuditRepository:
@staticmethod
def create(
db: Session,
*,
actor_user_id: int | None,
actor_username: str,
action: str,
entity_type: str,
entity_id: int | None,
entity_label: str,
ip_address: str,
user_agent: str,
before_data: dict | list | None = None,
after_data: dict | list | None = None,
metadata: dict | list | None = None,
) -> AuditLog:
audit_log = AuditLog(
actor_user_id=actor_user_id,
actor_username=actor_username,
action=action,
entity_type=entity_type,
entity_id=entity_id,
entity_label=entity_label,
ip_address=ip_address,
user_agent=user_agent,
before_data=before_data,
after_data=after_data,
metadata_data=metadata,
)
db.add(audit_log)
db.commit()
db.refresh(audit_log)
return audit_log
@staticmethod
def query(
*,
entity_type: str | None = None,
actor_user_id: int | None = None,
action: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> Select[tuple[AuditLog]]:
query = select(AuditLog)
if entity_type:
query = query.where(AuditLog.entity_type == entity_type)
if actor_user_id is not None:
query = query.where(AuditLog.actor_user_id == actor_user_id)
if action:
query = query.where(AuditLog.action == action)
if date_from:
query = query.where(AuditLog.created_at >= date_from)
if date_to:
query = query.where(AuditLog.created_at <= date_to)
return query
@staticmethod
def list(
db: Session,
*,
page: int,
page_size: int,
entity_type: str | None = None,
actor_user_id: int | None = None,
action: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[list[AuditLog], int]:
base_query = AuditRepository.query(
entity_type=entity_type,
actor_user_id=actor_user_id,
action=action,
date_from=date_from,
date_to=date_to,
)
total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0
items = list(
db.scalars(
base_query
.order_by(AuditLog.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
)
return items, total
@staticmethod
def latest(db: Session, limit: int = 10) -> list[AuditLog]:
return list(
db.scalars(
select(AuditLog)
.order_by(AuditLog.created_at.desc())
.limit(limit)
)
)

View file

@ -0,0 +1,16 @@
from typing import Any
from pydantic import BaseModel, Field
class ApiSuccess(BaseModel):
success: bool = True
data: Any = None
message: str = ""
class ApiError(BaseModel):
success: bool = False
message: str
error_code: str
details: list[Any] = Field(default_factory=list)

View file

@ -0,0 +1,39 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class AuditLogResponse(BaseModel):
id: int
actor_user_id: int | None
actor_username: str
action: str
entity_type: str
entity_id: int | None
entity_label: str
ip_address: str
user_agent: str
before_data: Any = None
after_data: Any = None
metadata_data: Any = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AuditLogListResponse(BaseModel):
items: list[AuditLogResponse]
total: int
page: int
page_size: int
class ActivityFeedItem(BaseModel):
id: int
title: str
description: str
actor: str
entity_type: str
entity_id: int | None
created_at: datetime

View file

@ -0,0 +1,148 @@
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", "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",
"customer_contacts.create": "Ansprechpartner erstellt",
"customer_contacts.update": "Ansprechpartner bearbeitet",
"customer_contacts.delete": "Ansprechpartner gelöscht",
}
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,
}

View file

@ -11,6 +11,7 @@ services:
SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports:
- "8000:8000"