feat: admin API, audit logging, notifications, create_admin script

Add audit_service for compliance logging, admin endpoints (user CRUD,
invitation management, audit log), notification endpoints (list, mark
read), and interactive create_admin script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-24 07:48:41 +00:00
parent 518de3da27
commit df26b51e14
6 changed files with 481 additions and 0 deletions

272
backend/app/api/admin.py Normal file
View file

@ -0,0 +1,272 @@
"""Admin-only API endpoints: user CRUD, invitation management, audit log."""
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.core.dependencies import require_admin
from app.core.security import hash_password
from app.database import get_db
from app.models.audit import AuditLog
from app.models.user import InvitationLink, User
from app.schemas.user import UserCreate, UserResponse, UserUpdate
from app.services.audit_service import log_action
router = APIRouter()
# ---------------------------------------------------------------------------
# Invitation schemas (kept co-located with the admin router for simplicity)
# ---------------------------------------------------------------------------
class InvitationCreate(BaseModel):
"""Payload for creating a new invitation link."""
email: Optional[EmailStr] = None
role: str = "dak_mitarbeiter"
expires_in_days: int = 7
class InvitationResponse(BaseModel):
"""Public representation of an invitation link."""
id: int
token: str
email: Optional[str] = None
role: str
created_by: Optional[int] = None
expires_at: datetime
used_at: Optional[datetime] = None
used_by: Optional[int] = None
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class AuditLogResponse(BaseModel):
"""Single audit-log entry returned by the admin endpoint."""
id: int
user_id: Optional[int] = None
action: str
entity_type: Optional[str] = None
entity_id: Optional[int] = None
old_values: Optional[dict] = None
new_values: Optional[dict] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
# ---------------------------------------------------------------------------
# Users CRUD
# ---------------------------------------------------------------------------
@router.get("/users", response_model=list[UserResponse])
def list_users(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Return a paginated list of all users."""
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
payload: UserCreate,
request: Request,
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Create a new user directly (admin bypass -- no invitation required)."""
existing = (
db.query(User)
.filter((User.email == payload.email) | (User.username == payload.username))
.first()
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email or username already exists",
)
user = User(
username=payload.username,
email=payload.email,
password_hash=hash_password(payload.password),
role=payload.role,
)
db.add(user)
db.commit()
db.refresh(user)
log_action(
db,
user_id=admin.id,
action="user_created",
entity_type="user",
entity_id=user.id,
new_values={"username": user.username, "email": user.email, "role": user.role},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return user
@router.put("/users/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
payload: UserUpdate,
request: Request,
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Update an existing user's profile fields."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
old_values: dict = {}
new_values: dict = {}
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
current = getattr(user, field)
if current != value:
old_values[field] = current
new_values[field] = value
setattr(user, field, value)
if new_values:
db.commit()
db.refresh(user)
log_action(
db,
user_id=admin.id,
action="user_updated",
entity_type="user",
entity_id=user.id,
old_values=old_values,
new_values=new_values,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return user
# ---------------------------------------------------------------------------
# Invitations
# ---------------------------------------------------------------------------
@router.post(
"/invitations",
response_model=InvitationResponse,
status_code=status.HTTP_201_CREATED,
)
def create_invitation(
payload: InvitationCreate,
request: Request,
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Generate a new invitation link (optionally bound to a specific email)."""
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(days=payload.expires_in_days)
invitation = InvitationLink(
token=token,
email=payload.email,
role=payload.role,
created_by=admin.id,
expires_at=expires_at,
)
db.add(invitation)
db.commit()
db.refresh(invitation)
log_action(
db,
user_id=admin.id,
action="invitation_created",
entity_type="invitation",
entity_id=invitation.id,
new_values={
"email": payload.email,
"role": payload.role,
"expires_at": expires_at.isoformat(),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
return invitation
@router.get("/invitations", response_model=list[InvitationResponse])
def list_invitations(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Return a paginated list of all invitation links."""
invitations = (
db.query(InvitationLink)
.order_by(InvitationLink.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return invitations
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
@router.get("/audit-log", response_model=list[AuditLogResponse])
def get_audit_log(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
user_id: Optional[int] = Query(None),
action: Optional[str] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Return a paginated, filterable audit log."""
query = db.query(AuditLog)
if user_id is not None:
query = query.filter(AuditLog.user_id == user_id)
if action is not None:
query = query.filter(AuditLog.action == action)
if date_from is not None:
query = query.filter(AuditLog.created_at >= date_from)
if date_to is not None:
query = query.filter(AuditLog.created_at <= date_to)
entries = (
query.order_by(AuditLog.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return entries

View file

@ -0,0 +1,83 @@
"""Notification API endpoints for authenticated users."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.dependencies import get_current_user
from app.database import get_db
from app.models.audit import Notification
from app.models.user import User
from app.schemas.notification import NotificationList, NotificationResponse
router = APIRouter()
@router.get("", response_model=NotificationList)
def list_notifications(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return the last 50 notifications for the current user plus the unread count."""
notifications = (
db.query(Notification)
.filter(Notification.recipient_id == user.id)
.order_by(Notification.created_at.desc())
.limit(50)
.all()
)
unread_count = (
db.query(Notification)
.filter(
Notification.recipient_id == user.id,
Notification.is_read == False, # noqa: E712
)
.count()
)
return NotificationList(items=notifications, unread_count=unread_count)
@router.put("/{notification_id}/read", response_model=NotificationResponse)
def mark_notification_read(
notification_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Mark a single notification as read (ownership is verified)."""
notification = (
db.query(Notification)
.filter(
Notification.id == notification_id,
Notification.recipient_id == user.id,
)
.first()
)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
)
notification.is_read = True
db.commit()
db.refresh(notification)
return notification
@router.put("/read-all", status_code=status.HTTP_200_OK)
def mark_all_notifications_read(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Mark all unread notifications as read for the current user."""
updated = (
db.query(Notification)
.filter(
Notification.recipient_id == user.id,
Notification.is_read == False, # noqa: E712
)
.update({"is_read": True})
)
db.commit()
return {"marked_read": updated}

View file

@ -2,7 +2,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.admin import router as admin_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.notifications import router as notifications_router
from app.config import get_settings from app.config import get_settings
settings = get_settings() settings = get_settings()
@ -19,6 +21,8 @@ app.add_middleware(
# --- Routers --- # --- Routers ---
app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
app.include_router(notifications_router, prefix="/api/notifications", tags=["notifications"])
@app.get("/api/health") @app.get("/api/health")

View file

@ -0,0 +1,28 @@
"""Pydantic v2 schemas for Notification responses."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class NotificationResponse(BaseModel):
"""Single notification returned to the client."""
id: int
notification_type: str
title: str
message: Optional[str] = None
related_entity_type: Optional[str] = None
related_entity_id: Optional[int] = None
is_read: bool
created_at: datetime
model_config = {"from_attributes": True}
class NotificationList(BaseModel):
"""Wrapper that includes an unread counter alongside the item list."""
items: list[NotificationResponse]
unread_count: int

View file

@ -0,0 +1,31 @@
"""Audit logging service -- records user actions for compliance and debugging."""
from sqlalchemy.orm import Session
from app.models.audit import AuditLog
def log_action(
db: Session,
user_id: int | None,
action: str,
entity_type: str | None = None,
entity_id: int | None = None,
old_values: dict | None = None,
new_values: dict | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
) -> None:
"""Persist a single audit-log entry and commit immediately."""
entry = AuditLog(
user_id=user_id,
action=action,
entity_type=entity_type,
entity_id=entity_id,
old_values=old_values,
new_values=new_values,
ip_address=ip_address,
user_agent=user_agent,
)
db.add(entry)
db.commit()

View file

@ -0,0 +1,63 @@
"""Create initial admin user interactively."""
import os
import sys
# Allow imports from the backend package when running this script directly.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from getpass import getpass
from app.core.security import hash_password
from app.database import SessionLocal
from app.models.user import User
def main() -> None:
db = SessionLocal()
try:
username = input("Admin username: ").strip()
if not username:
print("Username cannot be empty.")
sys.exit(1)
email = input("Admin email: ").strip()
if not email:
print("Email cannot be empty.")
sys.exit(1)
password = getpass("Admin password: ")
confirm = getpass("Confirm password: ")
if password != confirm:
print("Passwords don't match!")
sys.exit(1)
if len(password) < 8:
print("Password must be at least 8 characters.")
sys.exit(1)
existing = (
db.query(User)
.filter((User.email == email) | (User.username == username))
.first()
)
if existing:
print(f"User already exists: {existing.username} ({existing.email})")
sys.exit(1)
user = User(
username=username,
email=email,
password_hash=hash_password(password),
role="admin",
)
db.add(user)
db.commit()
print(f"Admin user created: {username} ({email})")
finally:
db.close()
if __name__ == "__main__":
main()