Files
breakpilot-compliance/backend-compliance/compliance/api/audit_routes.py
Sharang Parnerkar 3320ef94fc refactor: phase 0 guardrails + phase 1 step 2 (models.py split)
Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).

## Phase 0 — Architecture guardrails

Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:

  1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
     that would exceed the 500-line hard cap. Auto-loads in every Claude
     session in this repo.
  2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
     enforces the LOC cap locally, freezes migrations/ without
     [migration-approved], and protects guardrail files without
     [guardrail-change].
  3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
     sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
     packages (compliance/{services,repositories,domain,schemas}), and
     tsc --noEmit for admin-compliance + developer-portal.

Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.

scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.

## Deprecation sweep

47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.

DeprecationWarning count dropped from 158 to 35.

## Phase 1 Step 1 — Contract test harness

tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.

## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)

compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):

  regulation_models.py       (134) — Regulation, Requirement
  control_models.py          (279) — Control, Mapping, Evidence, Risk
  ai_system_models.py        (141) — AISystem, AuditExport
  service_module_models.py   (176) — ServiceModule, ModuleRegulation, ModuleRisk
  audit_session_models.py    (177) — AuditSession, AuditSignOff
  isms_governance_models.py  (323) — ISMSScope, Context, Policy, Objective, SoA
  isms_audit_models.py       (468) — Finding, CAPA, MgmtReview, InternalAudit,
                                     AuditTrail, Readiness

models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.

All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.

## Phase 1 Step 3 — infrastructure only

backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.

PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.

## Verification

  backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
  PYTHONPATH=. pytest compliance/tests/ tests/contracts/
  -> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:18:29 +02:00

638 lines
22 KiB
Python

"""
FastAPI routes for Audit Sessions & Sign-off functionality.
Sprint 3 Phase 3: Auditor-Verbesserungen
Endpoints:
- /audit/sessions: Manage audit sessions
- /audit/checklist: Audit checklist with sign-off
"""
import logging
from datetime import datetime, timezone
from typing import Optional, List
from uuid import uuid4
import hashlib
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from classroom_engine.database import get_db
from ..db.models import (
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
RequirementDB, RegulationDB, ControlMappingDB
)
from .schemas import (
CreateAuditSessionRequest, AuditSessionResponse, AuditSessionSummary, AuditSessionDetailResponse,
SignOffRequest, SignOffResponse,
AuditChecklistItem, AuditChecklistResponse, AuditStatistics,
PaginationMeta,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/audit", tags=["compliance-audit"])
# ============================================================================
# Audit Sessions
# ============================================================================
@router.post("/sessions", response_model=AuditSessionResponse)
async def create_audit_session(
request: CreateAuditSessionRequest,
db: Session = Depends(get_db),
):
"""
Create a new audit session for structured compliance reviews.
An audit session groups requirements for systematic review by an auditor.
"""
# Get total requirements count based on filters
query = db.query(RequirementDB)
if request.regulation_codes:
reg_ids = db.query(RegulationDB.id).filter(
RegulationDB.code.in_(request.regulation_codes)
).all()
reg_ids = [r[0] for r in reg_ids]
query = query.filter(RequirementDB.regulation_id.in_(reg_ids))
total_items = query.count()
# Create the session
session = AuditSessionDB(
id=str(uuid4()),
name=request.name,
description=request.description,
auditor_name=request.auditor_name,
auditor_email=request.auditor_email,
auditor_organization=request.auditor_organization,
status=AuditSessionStatusEnum.DRAFT,
regulation_ids=request.regulation_codes,
total_items=total_items,
completed_items=0,
compliant_count=0,
non_compliant_count=0,
)
db.add(session)
db.commit()
db.refresh(session)
return AuditSessionResponse(
id=session.id,
name=session.name,
description=session.description,
auditor_name=session.auditor_name,
auditor_email=session.auditor_email,
auditor_organization=session.auditor_organization,
status=session.status.value,
regulation_ids=session.regulation_ids,
total_items=session.total_items,
completed_items=session.completed_items,
compliant_count=session.compliant_count,
non_compliant_count=session.non_compliant_count,
completion_percentage=session.completion_percentage,
created_at=session.created_at,
started_at=session.started_at,
completed_at=session.completed_at,
)
@router.get("/sessions", response_model=List[AuditSessionSummary])
async def list_audit_sessions(
status: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
List all audit sessions, optionally filtered by status.
"""
query = db.query(AuditSessionDB)
if status:
try:
status_enum = AuditSessionStatusEnum(status)
query = query.filter(AuditSessionDB.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}. Valid values: draft, in_progress, completed, archived"
)
sessions = query.order_by(AuditSessionDB.created_at.desc()).all()
return [
AuditSessionSummary(
id=s.id,
name=s.name,
auditor_name=s.auditor_name,
status=s.status.value,
total_items=s.total_items,
completed_items=s.completed_items,
completion_percentage=s.completion_percentage,
created_at=s.created_at,
started_at=s.started_at,
completed_at=s.completed_at,
)
for s in sessions
]
@router.get("/sessions/{session_id}", response_model=AuditSessionDetailResponse)
async def get_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Get detailed information about a specific audit session.
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
# Get sign-off statistics
signoffs = db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).all()
stats = AuditStatistics(
total=session.total_items,
compliant=sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT),
compliant_with_notes=sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES),
non_compliant=sum(1 for s in signoffs if s.result == AuditResultEnum.NON_COMPLIANT),
not_applicable=sum(1 for s in signoffs if s.result == AuditResultEnum.NOT_APPLICABLE),
pending=session.total_items - len(signoffs),
completion_percentage=session.completion_percentage,
)
return AuditSessionDetailResponse(
id=session.id,
name=session.name,
description=session.description,
auditor_name=session.auditor_name,
auditor_email=session.auditor_email,
auditor_organization=session.auditor_organization,
status=session.status.value,
regulation_ids=session.regulation_ids,
total_items=session.total_items,
completed_items=session.completed_items,
compliant_count=session.compliant_count,
non_compliant_count=session.non_compliant_count,
completion_percentage=session.completion_percentage,
created_at=session.created_at,
started_at=session.started_at,
completed_at=session.completed_at,
statistics=stats,
)
@router.put("/sessions/{session_id}/start")
async def start_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Start an audit session (change status from draft to in_progress).
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status != AuditSessionStatusEnum.DRAFT:
raise HTTPException(
status_code=400,
detail=f"Session cannot be started. Current status: {session.status.value}"
)
session.status = AuditSessionStatusEnum.IN_PROGRESS
session.started_at = datetime.now(timezone.utc)
db.commit()
return {"success": True, "message": "Audit session started", "status": "in_progress"}
@router.put("/sessions/{session_id}/complete")
async def complete_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Complete an audit session (change status from in_progress to completed).
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status != AuditSessionStatusEnum.IN_PROGRESS:
raise HTTPException(
status_code=400,
detail=f"Session cannot be completed. Current status: {session.status.value}"
)
session.status = AuditSessionStatusEnum.COMPLETED
session.completed_at = datetime.now(timezone.utc)
db.commit()
return {"success": True, "message": "Audit session completed", "status": "completed"}
@router.put("/sessions/{session_id}/archive")
async def archive_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Archive a completed audit session.
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status != AuditSessionStatusEnum.COMPLETED:
raise HTTPException(
status_code=400,
detail=f"Only completed sessions can be archived. Current status: {session.status.value}"
)
session.status = AuditSessionStatusEnum.ARCHIVED
db.commit()
return {"success": True, "message": "Audit session archived", "status": "archived"}
@router.delete("/sessions/{session_id}")
async def delete_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Delete an audit session and all its sign-offs.
Only draft sessions can be deleted.
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status not in [AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.ARCHIVED]:
raise HTTPException(
status_code=400,
detail=f"Cannot delete session with status: {session.status.value}. Archive it first."
)
# Delete all sign-offs first (cascade should handle this, but be explicit)
db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).delete()
# Delete the session
db.delete(session)
db.commit()
return {"success": True, "message": f"Audit session {session_id} deleted"}
# ============================================================================
# Audit Checklist & Sign-off
# ============================================================================
@router.get("/checklist/{session_id}", response_model=AuditChecklistResponse)
async def get_audit_checklist(
session_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
status_filter: Optional[str] = None,
regulation_filter: Optional[str] = None,
search: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
Get the audit checklist for a session with pagination.
Returns requirements with their current sign-off status.
"""
# Get the session
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
# Build base query for requirements
query = db.query(RequirementDB).join(RegulationDB)
# Apply session's regulation filter
if session.regulation_ids:
query = query.filter(RegulationDB.code.in_(session.regulation_ids))
# Apply additional filters
if regulation_filter:
query = query.filter(RegulationDB.code == regulation_filter)
if search:
search_term = f"%{search}%"
query = query.filter(
(RequirementDB.title.ilike(search_term)) |
(RequirementDB.article.ilike(search_term)) |
(RequirementDB.description.ilike(search_term))
)
# Get total count before pagination
total_count = query.count()
# Apply pagination
requirements = (
query
.order_by(RegulationDB.code, RequirementDB.article)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
# Get existing sign-offs for these requirements
req_ids = [r.id for r in requirements]
signoffs = (
db.query(AuditSignOffDB)
.filter(AuditSignOffDB.session_id == session_id)
.filter(AuditSignOffDB.requirement_id.in_(req_ids))
.all()
)
signoff_map = {s.requirement_id: s for s in signoffs}
# Get control mappings counts
mapping_counts = (
db.query(ControlMappingDB.requirement_id, func.count(ControlMappingDB.id))
.filter(ControlMappingDB.requirement_id.in_(req_ids))
.group_by(ControlMappingDB.requirement_id)
.all()
)
mapping_count_map = dict(mapping_counts)
# Build checklist items
items = []
for req in requirements:
signoff = signoff_map.get(req.id)
# Apply status filter if specified
if status_filter:
if status_filter == "pending" and signoff is not None:
continue
elif status_filter != "pending" and (signoff is None or signoff.result.value != status_filter):
continue
item = AuditChecklistItem(
requirement_id=req.id,
regulation_code=req.regulation.code,
article=req.article,
paragraph=req.paragraph,
title=req.title,
description=req.description,
current_result=signoff.result.value if signoff else "pending",
notes=signoff.notes if signoff else None,
is_signed=signoff.signature_hash is not None if signoff else False,
signed_at=signoff.signed_at if signoff else None,
signed_by=signoff.signed_by if signoff else None,
evidence_count=0, # TODO: Add evidence count
controls_mapped=mapping_count_map.get(req.id, 0),
implementation_status=req.implementation_status,
priority=req.priority,
)
items.append(item)
# Calculate statistics
all_signoffs = db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).all()
stats = AuditStatistics(
total=session.total_items,
compliant=sum(1 for s in all_signoffs if s.result == AuditResultEnum.COMPLIANT),
compliant_with_notes=sum(1 for s in all_signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES),
non_compliant=sum(1 for s in all_signoffs if s.result == AuditResultEnum.NON_COMPLIANT),
not_applicable=sum(1 for s in all_signoffs if s.result == AuditResultEnum.NOT_APPLICABLE),
pending=session.total_items - len(all_signoffs),
completion_percentage=session.completion_percentage,
)
return AuditChecklistResponse(
session=AuditSessionSummary(
id=session.id,
name=session.name,
auditor_name=session.auditor_name,
status=session.status.value,
total_items=session.total_items,
completed_items=session.completed_items,
completion_percentage=session.completion_percentage,
created_at=session.created_at,
started_at=session.started_at,
completed_at=session.completed_at,
),
items=items,
pagination=PaginationMeta(
page=page,
page_size=page_size,
total=total_count,
total_pages=(total_count + page_size - 1) // page_size,
),
statistics=stats,
)
@router.put("/checklist/{session_id}/items/{requirement_id}/sign-off", response_model=SignOffResponse)
async def sign_off_item(
session_id: str,
requirement_id: str,
request: SignOffRequest,
db: Session = Depends(get_db),
):
"""
Sign off on a specific requirement in an audit session.
If sign=True, creates a digital signature (SHA-256 hash).
"""
# Validate session exists and is in progress
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status not in [AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.IN_PROGRESS]:
raise HTTPException(
status_code=400,
detail=f"Cannot sign off items in session with status: {session.status.value}"
)
# Validate requirement exists
requirement = db.query(RequirementDB).filter(RequirementDB.id == requirement_id).first()
if not requirement:
raise HTTPException(status_code=404, detail=f"Requirement {requirement_id} not found")
# Map string result to enum
try:
result_enum = AuditResultEnum(request.result)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid result: {request.result}. Valid values: compliant, compliant_notes, non_compliant, not_applicable, pending"
)
# Check if sign-off already exists
signoff = (
db.query(AuditSignOffDB)
.filter(AuditSignOffDB.session_id == session_id)
.filter(AuditSignOffDB.requirement_id == requirement_id)
.first()
)
was_new = signoff is None
old_result = signoff.result if signoff else None
if signoff:
# Update existing sign-off
signoff.result = result_enum
signoff.notes = request.notes
signoff.updated_at = datetime.now(timezone.utc)
else:
# Create new sign-off
signoff = AuditSignOffDB(
id=str(uuid4()),
session_id=session_id,
requirement_id=requirement_id,
result=result_enum,
notes=request.notes,
)
db.add(signoff)
# Create digital signature if requested
signature = None
if request.sign:
timestamp = datetime.now(timezone.utc).isoformat()
data = f"{result_enum.value}|{requirement_id}|{session.auditor_name}|{timestamp}"
signature = hashlib.sha256(data.encode()).hexdigest()
signoff.signature_hash = signature
signoff.signed_at = datetime.now(timezone.utc)
signoff.signed_by = session.auditor_name
# Update session statistics
if was_new:
session.completed_items += 1
# Update compliant/non-compliant counts
if old_result != result_enum:
if old_result == AuditResultEnum.COMPLIANT or old_result == AuditResultEnum.COMPLIANT_WITH_NOTES:
session.compliant_count = max(0, session.compliant_count - 1)
elif old_result == AuditResultEnum.NON_COMPLIANT:
session.non_compliant_count = max(0, session.non_compliant_count - 1)
if result_enum == AuditResultEnum.COMPLIANT or result_enum == AuditResultEnum.COMPLIANT_WITH_NOTES:
session.compliant_count += 1
elif result_enum == AuditResultEnum.NON_COMPLIANT:
session.non_compliant_count += 1
# Auto-start session if this is the first sign-off
if session.status == AuditSessionStatusEnum.DRAFT:
session.status = AuditSessionStatusEnum.IN_PROGRESS
session.started_at = datetime.now(timezone.utc)
db.commit()
db.refresh(signoff)
return SignOffResponse(
id=signoff.id,
session_id=signoff.session_id,
requirement_id=signoff.requirement_id,
result=signoff.result.value,
notes=signoff.notes,
is_signed=signoff.signature_hash is not None,
signature_hash=signoff.signature_hash,
signed_at=signoff.signed_at,
signed_by=signoff.signed_by,
created_at=signoff.created_at,
updated_at=signoff.updated_at,
)
@router.get("/checklist/{session_id}/items/{requirement_id}", response_model=SignOffResponse)
async def get_sign_off(
session_id: str,
requirement_id: str,
db: Session = Depends(get_db),
):
"""
Get the current sign-off status for a specific requirement.
"""
signoff = (
db.query(AuditSignOffDB)
.filter(AuditSignOffDB.session_id == session_id)
.filter(AuditSignOffDB.requirement_id == requirement_id)
.first()
)
if not signoff:
raise HTTPException(
status_code=404,
detail=f"No sign-off found for requirement {requirement_id} in session {session_id}"
)
return SignOffResponse(
id=signoff.id,
session_id=signoff.session_id,
requirement_id=signoff.requirement_id,
result=signoff.result.value,
notes=signoff.notes,
is_signed=signoff.signature_hash is not None,
signature_hash=signoff.signature_hash,
signed_at=signoff.signed_at,
signed_by=signoff.signed_by,
created_at=signoff.created_at,
updated_at=signoff.updated_at,
)
# ============================================================================
# PDF Report Generation
# ============================================================================
@router.get("/sessions/{session_id}/report/pdf")
async def generate_audit_pdf_report(
session_id: str,
language: str = Query("de", pattern="^(de|en)$"),
include_signatures: bool = Query(True),
db: Session = Depends(get_db),
):
"""
Generate a PDF report for an audit session.
Parameters:
- session_id: The audit session ID
- language: Output language ('de' or 'en'), default 'de'
- include_signatures: Include digital signature verification section
Returns:
- PDF file as streaming response
"""
from fastapi.responses import StreamingResponse
import io
from ..services.audit_pdf_generator import AuditPDFGenerator
# Validate session exists
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(
status_code=404,
detail=f"Audit session {session_id} not found"
)
try:
generator = AuditPDFGenerator(db)
pdf_bytes, filename = generator.generate(
session_id=session_id,
language=language,
include_signatures=include_signatures,
)
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
}
)
except Exception as e:
logger.error(f"Failed to generate PDF report: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to generate PDF report: {str(e)}"
)