From df26b51e14c038af3f3095e2de3f8a9bfe2db733 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Tue, 24 Feb 2026 07:48:41 +0000 Subject: [PATCH] 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 --- backend/app/api/admin.py | 272 ++++++++++++++++++++++++++ backend/app/api/notifications.py | 83 ++++++++ backend/app/main.py | 4 + backend/app/schemas/notification.py | 28 +++ backend/app/services/audit_service.py | 31 +++ backend/scripts/create_admin.py | 63 ++++++ 6 files changed, 481 insertions(+) create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/api/notifications.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/services/audit_service.py create mode 100644 backend/scripts/create_admin.py diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..0d50a0f --- /dev/null +++ b/backend/app/api/admin.py @@ -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 diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..4cbf5c3 --- /dev/null +++ b/backend/app/api/notifications.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index 6a98e9f..e2c925a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,9 @@ from fastapi import FastAPI 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.notifications import router as notifications_router from app.config import get_settings settings = get_settings() @@ -19,6 +21,8 @@ app.add_middleware( # --- Routers --- 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") diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..bcba146 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..09051bb --- /dev/null +++ b/backend/app/services/audit_service.py @@ -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() diff --git a/backend/scripts/create_admin.py b/backend/scripts/create_admin.py new file mode 100644 index 0000000..b9742df --- /dev/null +++ b/backend/scripts/create_admin.py @@ -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()