feat(platform): add audit logs and activity feed
This commit is contained in:
parent
92cb8d1286
commit
c816e9869d
34 changed files with 1592 additions and 43 deletions
|
|
@ -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")
|
||||
80
backend/hermes/app/api/audit.py
Normal file
80
backend/hermes/app/api/audit.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
14
backend/hermes/app/core/logging.py
Normal file
14
backend/hermes/app/core/logging.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
28
backend/hermes/app/models/audit.py
Normal file
28
backend/hermes/app/models/audit.py
Normal 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())
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
104
backend/hermes/app/repositories/audit_repository.py
Normal file
104
backend/hermes/app/repositories/audit_repository.py
Normal 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)
|
||||
)
|
||||
)
|
||||
16
backend/hermes/app/schemas/api_response.py
Normal file
16
backend/hermes/app/schemas/api_response.py
Normal 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)
|
||||
39
backend/hermes/app/schemas/audit.py
Normal file
39
backend/hermes/app/schemas/audit.py
Normal 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
|
||||
148
backend/hermes/app/services/audit_service.py
Normal file
148
backend/hermes/app/services/audit_service.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue