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,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)