feat(phase-9): implement MVP polishing and closure

T-032: User management admin panel - backend users router with CRUD, frontend UsersPage with modals

T-033: Audit log viewer - backend audit router with filters/pagination, frontend AuditLogPage

T-034: Global error handling - ErrorBoundary, LoadingSpinner, ErrorMessage, Toast components

T-035: Backend tests - pytest setup with SQLite, tests for health/auth/techniques/tests

T-036: Documentation - Updated README with testing section, created docs/API.md
This commit is contained in:
2026-02-06 16:30:35 +01:00
parent cb447f3803
commit 174919da4e
27 changed files with 2539 additions and 17 deletions

View File

@@ -1,8 +1,11 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import SQLAlchemyError
from app.routers import auth as auth_router
from app.routers import techniques as techniques_router
@@ -10,6 +13,8 @@ from app.routers import tests as tests_router
from app.routers import evidence as evidence_router
from app.routers import system as system_router
from app.routers import metrics as metrics_router
from app.routers import users as users_router
from app.routers import audit as audit_router
from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler
@@ -47,8 +52,52 @@ app.include_router(tests_router.router, prefix="/api/v1")
app.include_router(evidence_router.router, prefix="/api/v1")
app.include_router(system_router.router, prefix="/api/v1")
app.include_router(metrics_router.router, prefix="/api/v1")
app.include_router(users_router.router, prefix="/api/v1")
app.include_router(audit_router.router, prefix="/api/v1")
@app.get("/health")
def health():
return {"status": "ok"}
# ── Exception Handlers ────────────────────────────────────────────────────
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle validation errors with consistent format."""
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"detail": "Validation error",
"code": "VALIDATION_ERROR",
"errors": exc.errors(),
},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
"""Handle database errors."""
logging.error(f"Database error: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"detail": "Database error occurred",
"code": "DATABASE_ERROR",
},
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Handle all unhandled exceptions."""
logging.error(f"Unhandled exception: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"detail": "An internal server error occurred",
"code": "INTERNAL_ERROR",
},
)

View File

@@ -0,0 +1,118 @@
"""Audit log viewer router (admin only)."""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import require_role
from app.models.audit import AuditLog
from app.models.user import User
from app.schemas.audit import AuditLogOut, AuditLogPage
router = APIRouter(prefix="/audit-logs", tags=["audit"])
@router.get("", response_model=AuditLogPage)
def list_audit_logs(
user_id: Optional[str] = Query(None, description="Filter by user ID"),
action: Optional[str] = Query(None, description="Filter by action type"),
entity_type: Optional[str] = Query(None, description="Filter by entity type"),
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Max records to return"),
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return paginated audit logs with optional filters.
**Requires admin role.**
"""
query = db.query(AuditLog).options(joinedload(AuditLog.user))
# Apply filters
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if action:
query = query.filter(AuditLog.action == action)
if entity_type:
query = query.filter(AuditLog.entity_type == entity_type)
if start_date:
query = query.filter(AuditLog.timestamp >= start_date)
if end_date:
query = query.filter(AuditLog.timestamp <= end_date)
# Get total count
total = query.count()
# Get paginated results
logs = (
query
.order_by(AuditLog.timestamp.desc())
.offset(offset)
.limit(limit)
.all()
)
# Convert to response format with username
items = []
for log in logs:
item = AuditLogOut(
id=log.id,
user_id=log.user_id,
username=log.user.username if log.user else None,
action=log.action,
entity_type=log.entity_type,
entity_id=log.entity_id,
timestamp=log.timestamp,
details=log.details,
)
items.append(item)
return AuditLogPage(
items=items,
total=total,
offset=offset,
limit=limit,
)
@router.get("/actions", response_model=list[str])
def list_actions(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a list of distinct action types in the audit log.
**Requires admin role.**
"""
actions = (
db.query(AuditLog.action)
.distinct()
.order_by(AuditLog.action)
.all()
)
return [a[0] for a in actions]
@router.get("/entity-types", response_model=list[str])
def list_entity_types(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a list of distinct entity types in the audit log.
**Requires admin role.**
"""
types = (
db.query(AuditLog.entity_type)
.filter(AuditLog.entity_type.isnot(None))
.distinct()
.order_by(AuditLog.entity_type)
.all()
)
return [t[0] for t in types]

View File

@@ -0,0 +1,153 @@
"""User management router (admin only)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import require_role
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserOut
from app.auth import hash_password
from app.services.audit_service import log_action
router = APIRouter(prefix="/users", tags=["users"])
VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"}
# ---------------------------------------------------------------------------
# GET /users — list all users
# ---------------------------------------------------------------------------
@router.get("", response_model=list[UserOut])
def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a list of all users. **Requires admin role.**"""
return db.query(User).order_by(User.username).all()
# ---------------------------------------------------------------------------
# POST /users — create a new user
# ---------------------------------------------------------------------------
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(
payload: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Create a new user. **Requires admin role.**"""
# Check if username already exists
existing = db.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{payload.username}' already exists",
)
# Validate role
if payload.role not in VALID_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role '{payload.role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
)
user = User(
username=payload.username,
email=payload.email,
hashed_password=hash_password(payload.password),
role=payload.role,
)
db.add(user)
db.commit()
db.refresh(user)
log_action(
db,
user_id=current_user.id,
action="create_user",
entity_type="user",
entity_id=user.id,
details={"username": user.username, "role": user.role},
)
return user
# ---------------------------------------------------------------------------
# GET /users/{id} — get a single user
# ---------------------------------------------------------------------------
@router.get("/{user_id}", response_model=UserOut)
def get_user(
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a single user by ID. **Requires admin role.**"""
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
# ---------------------------------------------------------------------------
# PATCH /users/{id} — update a user
# ---------------------------------------------------------------------------
@router.patch("/{user_id}", response_model=UserOut)
def update_user(
user_id: uuid.UUID,
payload: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Update one or more fields of an existing user. **Requires admin role.**"""
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
update_data = payload.model_dump(exclude_unset=True)
# Validate role if being updated
if "role" in update_data and update_data["role"] not in VALID_ROLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
)
# Hash password if being updated
if "password" in update_data:
update_data["hashed_password"] = hash_password(update_data.pop("password"))
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
log_action(
db,
user_id=current_user.id,
action="update_user",
entity_type="user",
entity_id=user.id,
details={"updated_fields": list(payload.model_dump(exclude_unset=True).keys())},
)
return user

View File

@@ -0,0 +1,31 @@
"""Pydantic schemas for Audit Log endpoints."""
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class AuditLogOut(BaseModel):
"""Complete representation of an audit log entry."""
id: uuid.UUID
user_id: uuid.UUID | None = None
username: str | None = None # Populated from user relationship
action: str
entity_type: str | None = None
entity_id: str | None = None
timestamp: datetime
details: dict[str, Any] | None = None
model_config = ConfigDict(from_attributes=True)
class AuditLogPage(BaseModel):
"""Paginated response for audit logs."""
items: list[AuditLogOut]
total: int
offset: int
limit: int

View File

@@ -0,0 +1,45 @@
"""Pydantic schemas for User management endpoints."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
# ── Create ──────────────────────────────────────────────────────────
class UserCreate(BaseModel):
"""Payload for creating a new user."""
username: str
email: str | None = None
password: str
role: str = "viewer"
# ── Update ──────────────────────────────────────────────────────────
class UserUpdate(BaseModel):
"""Payload for partially updating an existing user.
Every field is optional so callers send only what changed."""
email: str | None = None
role: str | None = None
is_active: bool | None = None
password: str | None = None
# ── Read (full) ─────────────────────────────────────────────────────
class UserOut(BaseModel):
"""Complete representation returned by the API."""
id: uuid.UUID
username: str
email: str | None = None
role: str
is_active: bool
created_at: datetime | None = None
last_login: datetime | None = None
model_config = ConfigDict(from_attributes=True)