Implement full evidence integrity pipeline to prevent compliance theater: - Confidence levels (E0-E4), truth status tracking, assertion engine - Four-Eyes approval workflow, audit trail, reject endpoint - Evidence distribution dashboard, LLM audit routes - Traceability matrix (backend endpoint + Compliance Hub UI tab) - Anti-fake badges, control status machine, normative patterns - 2 migrations, 4 test suites, MkDocs documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1032 lines
38 KiB
Python
1032 lines
38 KiB
Python
"""
|
|
FastAPI routes for Compliance module.
|
|
|
|
Endpoints:
|
|
- /regulations: Manage regulations
|
|
- /requirements: Manage requirements
|
|
- /controls: Manage controls
|
|
- /mappings: Requirement-Control mappings
|
|
- /evidence: Evidence management
|
|
- /risks: Risk management
|
|
- /dashboard: Dashboard statistics
|
|
- /export: Audit export
|
|
"""
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
import os
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
from .audit_trail_utils import log_audit_trail
|
|
from ..db import (
|
|
RegulationRepository,
|
|
RequirementRepository,
|
|
ControlRepository,
|
|
EvidenceRepository,
|
|
ControlStatusEnum,
|
|
ControlDomainEnum,
|
|
)
|
|
from ..db.models import EvidenceDB, ControlDB
|
|
from ..services.seeder import ComplianceSeeder
|
|
from ..services.export_generator import AuditExportGenerator
|
|
from .schemas import (
|
|
RegulationResponse, RegulationListResponse,
|
|
RequirementCreate, RequirementResponse, RequirementListResponse,
|
|
ControlUpdate, ControlResponse, ControlListResponse, ControlReviewRequest,
|
|
ExportRequest, ExportResponse, ExportListResponse,
|
|
SeedRequest, SeedResponse,
|
|
# Pagination schemas
|
|
PaginationMeta, PaginatedRequirementResponse, PaginatedControlResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/compliance", tags=["compliance"])
|
|
|
|
|
|
# ============================================================================
|
|
# Regulations
|
|
# ============================================================================
|
|
|
|
@router.get("/regulations", response_model=RegulationListResponse)
|
|
async def list_regulations(
|
|
is_active: Optional[bool] = None,
|
|
regulation_type: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List all regulations."""
|
|
repo = RegulationRepository(db)
|
|
if is_active is not None:
|
|
regulations = repo.get_active() if is_active else repo.get_all()
|
|
else:
|
|
regulations = repo.get_all()
|
|
|
|
if regulation_type:
|
|
from ..db.models import RegulationTypeEnum
|
|
try:
|
|
reg_type = RegulationTypeEnum(regulation_type)
|
|
regulations = [r for r in regulations if r.regulation_type == reg_type]
|
|
except ValueError:
|
|
pass
|
|
|
|
# Add requirement counts
|
|
req_repo = RequirementRepository(db)
|
|
results = []
|
|
for reg in regulations:
|
|
reqs = req_repo.get_by_regulation(reg.id)
|
|
reg_dict = {
|
|
"id": reg.id,
|
|
"code": reg.code,
|
|
"name": reg.name,
|
|
"full_name": reg.full_name,
|
|
"regulation_type": reg.regulation_type.value if reg.regulation_type else None,
|
|
"source_url": reg.source_url,
|
|
"local_pdf_path": reg.local_pdf_path,
|
|
"effective_date": reg.effective_date,
|
|
"description": reg.description,
|
|
"is_active": reg.is_active,
|
|
"created_at": reg.created_at,
|
|
"updated_at": reg.updated_at,
|
|
"requirement_count": len(reqs),
|
|
}
|
|
results.append(RegulationResponse(**reg_dict))
|
|
|
|
return RegulationListResponse(regulations=results, total=len(results))
|
|
|
|
|
|
@router.get("/regulations/{code}", response_model=RegulationResponse)
|
|
async def get_regulation(code: str, db: Session = Depends(get_db)):
|
|
"""Get a specific regulation by code."""
|
|
repo = RegulationRepository(db)
|
|
regulation = repo.get_by_code(code)
|
|
if not regulation:
|
|
raise HTTPException(status_code=404, detail=f"Regulation {code} not found")
|
|
|
|
req_repo = RequirementRepository(db)
|
|
reqs = req_repo.get_by_regulation(regulation.id)
|
|
|
|
return RegulationResponse(
|
|
id=regulation.id,
|
|
code=regulation.code,
|
|
name=regulation.name,
|
|
full_name=regulation.full_name,
|
|
regulation_type=regulation.regulation_type.value if regulation.regulation_type else None,
|
|
source_url=regulation.source_url,
|
|
local_pdf_path=regulation.local_pdf_path,
|
|
effective_date=regulation.effective_date,
|
|
description=regulation.description,
|
|
is_active=regulation.is_active,
|
|
created_at=regulation.created_at,
|
|
updated_at=regulation.updated_at,
|
|
requirement_count=len(reqs),
|
|
)
|
|
|
|
|
|
@router.get("/regulations/{code}/requirements", response_model=RequirementListResponse)
|
|
async def get_regulation_requirements(
|
|
code: str,
|
|
is_applicable: Optional[bool] = None,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get requirements for a specific regulation."""
|
|
reg_repo = RegulationRepository(db)
|
|
regulation = reg_repo.get_by_code(code)
|
|
if not regulation:
|
|
raise HTTPException(status_code=404, detail=f"Regulation {code} not found")
|
|
|
|
req_repo = RequirementRepository(db)
|
|
if is_applicable is not None:
|
|
requirements = req_repo.get_applicable(regulation.id) if is_applicable else req_repo.get_by_regulation(regulation.id)
|
|
else:
|
|
requirements = req_repo.get_by_regulation(regulation.id)
|
|
|
|
results = [
|
|
RequirementResponse(
|
|
id=r.id,
|
|
regulation_id=r.regulation_id,
|
|
regulation_code=code,
|
|
article=r.article,
|
|
paragraph=r.paragraph,
|
|
title=r.title,
|
|
description=r.description,
|
|
requirement_text=r.requirement_text,
|
|
breakpilot_interpretation=r.breakpilot_interpretation,
|
|
is_applicable=r.is_applicable,
|
|
applicability_reason=r.applicability_reason,
|
|
priority=r.priority,
|
|
created_at=r.created_at,
|
|
updated_at=r.updated_at,
|
|
)
|
|
for r in requirements
|
|
]
|
|
|
|
return RequirementListResponse(requirements=results, total=len(results))
|
|
|
|
|
|
@router.get("/requirements/{requirement_id}")
|
|
async def get_requirement(
|
|
requirement_id: str,
|
|
include_legal_context: bool = Query(False, description="Include RAG legal context"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get a specific requirement by ID, optionally with RAG legal context."""
|
|
from ..db.models import RequirementDB, RegulationDB
|
|
|
|
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")
|
|
|
|
regulation = db.query(RegulationDB).filter(RegulationDB.id == requirement.regulation_id).first()
|
|
|
|
result = {
|
|
"id": requirement.id,
|
|
"regulation_id": requirement.regulation_id,
|
|
"regulation_code": regulation.code if regulation else None,
|
|
"article": requirement.article,
|
|
"paragraph": requirement.paragraph,
|
|
"title": requirement.title,
|
|
"description": requirement.description,
|
|
"requirement_text": requirement.requirement_text,
|
|
"breakpilot_interpretation": requirement.breakpilot_interpretation,
|
|
"implementation_status": requirement.implementation_status or "not_started",
|
|
"implementation_details": requirement.implementation_details,
|
|
"code_references": requirement.code_references,
|
|
"documentation_links": requirement.documentation_links,
|
|
"evidence_description": requirement.evidence_description,
|
|
"evidence_artifacts": requirement.evidence_artifacts,
|
|
"auditor_notes": requirement.auditor_notes,
|
|
"audit_status": requirement.audit_status or "pending",
|
|
"last_audit_date": requirement.last_audit_date,
|
|
"last_auditor": requirement.last_auditor,
|
|
"is_applicable": requirement.is_applicable,
|
|
"applicability_reason": requirement.applicability_reason,
|
|
"priority": requirement.priority,
|
|
"source_page": requirement.source_page,
|
|
"source_section": requirement.source_section,
|
|
}
|
|
|
|
if include_legal_context:
|
|
try:
|
|
from ..services.rag_client import get_rag_client
|
|
from ..services.ai_compliance_assistant import AIComplianceAssistant
|
|
|
|
rag = get_rag_client()
|
|
assistant = AIComplianceAssistant()
|
|
query = f"{requirement.title} {requirement.article or ''}"
|
|
collection = assistant._collection_for_regulation(regulation.code if regulation else "")
|
|
rag_results = await rag.search(query, collection=collection, top_k=3)
|
|
result["legal_context"] = [
|
|
{
|
|
"text": r.text,
|
|
"regulation_code": r.regulation_code,
|
|
"regulation_short": r.regulation_short,
|
|
"article": r.article,
|
|
"score": r.score,
|
|
"source_url": r.source_url,
|
|
}
|
|
for r in rag_results
|
|
]
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch legal context for %s: %s", requirement_id, e)
|
|
result["legal_context"] = []
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/requirements", response_model=PaginatedRequirementResponse)
|
|
async def list_requirements_paginated(
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(50, ge=1, le=500, description="Items per page"),
|
|
regulation_code: Optional[str] = Query(None, description="Filter by regulation code"),
|
|
status: Optional[str] = Query(None, description="Filter by implementation status"),
|
|
is_applicable: Optional[bool] = Query(None, description="Filter by applicability"),
|
|
search: Optional[str] = Query(None, description="Search in title/description"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
List requirements with pagination and eager-loaded relationships.
|
|
|
|
This endpoint is optimized for large datasets (1000+ requirements) with:
|
|
- Eager loading to prevent N+1 queries
|
|
- Server-side pagination
|
|
- Full-text search support
|
|
"""
|
|
req_repo = RequirementRepository(db)
|
|
|
|
# Use the new paginated method with eager loading
|
|
requirements, total = req_repo.get_paginated(
|
|
page=page,
|
|
page_size=page_size,
|
|
regulation_code=regulation_code,
|
|
status=status,
|
|
is_applicable=is_applicable,
|
|
search=search,
|
|
)
|
|
|
|
# Calculate pagination metadata
|
|
total_pages = (total + page_size - 1) // page_size
|
|
|
|
results = [
|
|
RequirementResponse(
|
|
id=r.id,
|
|
regulation_id=r.regulation_id,
|
|
regulation_code=r.regulation.code if r.regulation else None,
|
|
article=r.article,
|
|
paragraph=r.paragraph,
|
|
title=r.title,
|
|
description=r.description,
|
|
requirement_text=r.requirement_text,
|
|
breakpilot_interpretation=r.breakpilot_interpretation,
|
|
is_applicable=r.is_applicable,
|
|
applicability_reason=r.applicability_reason,
|
|
priority=r.priority,
|
|
implementation_status=r.implementation_status or "not_started",
|
|
implementation_details=r.implementation_details,
|
|
code_references=r.code_references,
|
|
documentation_links=r.documentation_links,
|
|
evidence_description=r.evidence_description,
|
|
evidence_artifacts=r.evidence_artifacts,
|
|
auditor_notes=r.auditor_notes,
|
|
audit_status=r.audit_status or "pending",
|
|
last_audit_date=r.last_audit_date,
|
|
last_auditor=r.last_auditor,
|
|
source_page=r.source_page,
|
|
source_section=r.source_section,
|
|
created_at=r.created_at,
|
|
updated_at=r.updated_at,
|
|
)
|
|
for r in requirements
|
|
]
|
|
|
|
return PaginatedRequirementResponse(
|
|
data=results,
|
|
pagination=PaginationMeta(
|
|
page=page,
|
|
page_size=page_size,
|
|
total=total,
|
|
total_pages=total_pages,
|
|
has_next=page < total_pages,
|
|
has_prev=page > 1,
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("/requirements", response_model=RequirementResponse)
|
|
async def create_requirement(
|
|
data: RequirementCreate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a new requirement."""
|
|
# Verify regulation exists
|
|
reg_repo = RegulationRepository(db)
|
|
regulation = reg_repo.get_by_id(data.regulation_id)
|
|
if not regulation:
|
|
raise HTTPException(status_code=404, detail=f"Regulation {data.regulation_id} not found")
|
|
|
|
req_repo = RequirementRepository(db)
|
|
requirement = req_repo.create(
|
|
regulation_id=data.regulation_id,
|
|
article=data.article,
|
|
title=data.title,
|
|
paragraph=data.paragraph,
|
|
description=data.description,
|
|
requirement_text=data.requirement_text,
|
|
breakpilot_interpretation=data.breakpilot_interpretation,
|
|
is_applicable=data.is_applicable,
|
|
priority=data.priority,
|
|
)
|
|
|
|
return RequirementResponse(
|
|
id=requirement.id,
|
|
regulation_id=requirement.regulation_id,
|
|
regulation_code=regulation.code,
|
|
article=requirement.article,
|
|
paragraph=requirement.paragraph,
|
|
title=requirement.title,
|
|
description=requirement.description,
|
|
requirement_text=requirement.requirement_text,
|
|
breakpilot_interpretation=requirement.breakpilot_interpretation,
|
|
is_applicable=requirement.is_applicable,
|
|
applicability_reason=requirement.applicability_reason,
|
|
priority=requirement.priority,
|
|
created_at=requirement.created_at,
|
|
updated_at=requirement.updated_at,
|
|
)
|
|
|
|
|
|
@router.delete("/requirements/{requirement_id}")
|
|
async def delete_requirement(requirement_id: str, db: Session = Depends(get_db)):
|
|
"""Delete a requirement by ID."""
|
|
req_repo = RequirementRepository(db)
|
|
deleted = req_repo.delete(requirement_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail=f"Requirement {requirement_id} not found")
|
|
return {"success": True, "message": "Requirement deleted"}
|
|
|
|
|
|
@router.put("/requirements/{requirement_id}")
|
|
async def update_requirement(requirement_id: str, updates: dict, db: Session = Depends(get_db)):
|
|
"""Update a requirement with implementation/audit details."""
|
|
from ..db.models import RequirementDB
|
|
|
|
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")
|
|
|
|
# Allowed fields to update
|
|
allowed_fields = [
|
|
'implementation_status', 'implementation_details', 'code_references',
|
|
'documentation_links', 'evidence_description', 'evidence_artifacts',
|
|
'auditor_notes', 'audit_status', 'is_applicable', 'applicability_reason',
|
|
'breakpilot_interpretation'
|
|
]
|
|
|
|
for field in allowed_fields:
|
|
if field in updates:
|
|
setattr(requirement, field, updates[field])
|
|
|
|
# Track audit changes
|
|
if 'audit_status' in updates:
|
|
requirement.last_audit_date = datetime.utcnow()
|
|
# TODO: Get auditor from auth
|
|
requirement.last_auditor = updates.get('auditor_name', 'api_user')
|
|
|
|
requirement.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(requirement)
|
|
|
|
return {"success": True, "message": "Requirement updated"}
|
|
|
|
|
|
# ============================================================================
|
|
# Controls
|
|
# ============================================================================
|
|
|
|
@router.get("/controls", response_model=ControlListResponse)
|
|
async def list_controls(
|
|
domain: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
is_automated: Optional[bool] = None,
|
|
search: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List all controls with optional filters."""
|
|
repo = ControlRepository(db)
|
|
|
|
if domain:
|
|
try:
|
|
domain_enum = ControlDomainEnum(domain)
|
|
controls = repo.get_by_domain(domain_enum)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid domain: {domain}")
|
|
elif status:
|
|
try:
|
|
status_enum = ControlStatusEnum(status)
|
|
controls = repo.get_by_status(status_enum)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
|
|
else:
|
|
controls = repo.get_all()
|
|
|
|
# Apply additional filters
|
|
if is_automated is not None:
|
|
controls = [c for c in controls if c.is_automated == is_automated]
|
|
|
|
if search:
|
|
search_lower = search.lower()
|
|
controls = [
|
|
c for c in controls
|
|
if search_lower in c.control_id.lower()
|
|
or search_lower in c.title.lower()
|
|
or (c.description and search_lower in c.description.lower())
|
|
]
|
|
|
|
# Add counts
|
|
evidence_repo = EvidenceRepository(db)
|
|
results = []
|
|
for ctrl in controls:
|
|
evidence = evidence_repo.get_by_control(ctrl.id)
|
|
results.append(ControlResponse(
|
|
id=ctrl.id,
|
|
control_id=ctrl.control_id,
|
|
domain=ctrl.domain.value if ctrl.domain else None,
|
|
control_type=ctrl.control_type.value if ctrl.control_type else None,
|
|
title=ctrl.title,
|
|
description=ctrl.description,
|
|
pass_criteria=ctrl.pass_criteria,
|
|
implementation_guidance=ctrl.implementation_guidance,
|
|
code_reference=ctrl.code_reference,
|
|
documentation_url=ctrl.documentation_url,
|
|
is_automated=ctrl.is_automated,
|
|
automation_tool=ctrl.automation_tool,
|
|
automation_config=ctrl.automation_config,
|
|
owner=ctrl.owner,
|
|
review_frequency_days=ctrl.review_frequency_days,
|
|
status=ctrl.status.value if ctrl.status else None,
|
|
status_notes=ctrl.status_notes,
|
|
last_reviewed_at=ctrl.last_reviewed_at,
|
|
next_review_at=ctrl.next_review_at,
|
|
created_at=ctrl.created_at,
|
|
updated_at=ctrl.updated_at,
|
|
evidence_count=len(evidence),
|
|
))
|
|
|
|
return ControlListResponse(controls=results, total=len(results))
|
|
|
|
|
|
@router.get("/controls/paginated", response_model=PaginatedControlResponse)
|
|
async def list_controls_paginated(
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(50, ge=1, le=500, description="Items per page"),
|
|
domain: Optional[str] = Query(None, description="Filter by domain"),
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|
is_automated: Optional[bool] = Query(None, description="Filter by automation"),
|
|
search: Optional[str] = Query(None, description="Search in title/description"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
List controls with pagination and eager-loaded relationships.
|
|
|
|
This endpoint is optimized for large datasets with:
|
|
- Eager loading to prevent N+1 queries
|
|
- Server-side pagination
|
|
- Full-text search support
|
|
"""
|
|
repo = ControlRepository(db)
|
|
|
|
# Convert domain/status to enums if provided
|
|
domain_enum = None
|
|
status_enum = None
|
|
if domain:
|
|
try:
|
|
domain_enum = ControlDomainEnum(domain)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid domain: {domain}")
|
|
if status:
|
|
try:
|
|
status_enum = ControlStatusEnum(status)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
|
|
|
|
controls, total = repo.get_paginated(
|
|
page=page,
|
|
page_size=page_size,
|
|
domain=domain_enum,
|
|
status=status_enum,
|
|
is_automated=is_automated,
|
|
search=search,
|
|
)
|
|
|
|
total_pages = (total + page_size - 1) // page_size
|
|
|
|
results = [
|
|
ControlResponse(
|
|
id=c.id,
|
|
control_id=c.control_id,
|
|
domain=c.domain.value if c.domain else None,
|
|
control_type=c.control_type.value if c.control_type else None,
|
|
title=c.title,
|
|
description=c.description,
|
|
pass_criteria=c.pass_criteria,
|
|
implementation_guidance=c.implementation_guidance,
|
|
code_reference=c.code_reference,
|
|
documentation_url=c.documentation_url,
|
|
is_automated=c.is_automated,
|
|
automation_tool=c.automation_tool,
|
|
automation_config=c.automation_config,
|
|
owner=c.owner,
|
|
review_frequency_days=c.review_frequency_days,
|
|
status=c.status.value if c.status else None,
|
|
status_notes=c.status_notes,
|
|
last_reviewed_at=c.last_reviewed_at,
|
|
next_review_at=c.next_review_at,
|
|
created_at=c.created_at,
|
|
updated_at=c.updated_at,
|
|
evidence_count=len(c.evidence) if c.evidence else 0,
|
|
)
|
|
for c in controls
|
|
]
|
|
|
|
return PaginatedControlResponse(
|
|
data=results,
|
|
pagination=PaginationMeta(
|
|
page=page,
|
|
page_size=page_size,
|
|
total=total,
|
|
total_pages=total_pages,
|
|
has_next=page < total_pages,
|
|
has_prev=page > 1,
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/controls/{control_id}", response_model=ControlResponse)
|
|
async def get_control(control_id: str, db: Session = Depends(get_db)):
|
|
"""Get a specific control by control_id."""
|
|
repo = ControlRepository(db)
|
|
control = repo.get_by_control_id(control_id)
|
|
if not control:
|
|
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
|
|
|
|
evidence_repo = EvidenceRepository(db)
|
|
evidence = evidence_repo.get_by_control(control.id)
|
|
|
|
return ControlResponse(
|
|
id=control.id,
|
|
control_id=control.control_id,
|
|
domain=control.domain.value if control.domain else None,
|
|
control_type=control.control_type.value if control.control_type else None,
|
|
title=control.title,
|
|
description=control.description,
|
|
pass_criteria=control.pass_criteria,
|
|
implementation_guidance=control.implementation_guidance,
|
|
code_reference=control.code_reference,
|
|
documentation_url=control.documentation_url,
|
|
is_automated=control.is_automated,
|
|
automation_tool=control.automation_tool,
|
|
automation_config=control.automation_config,
|
|
owner=control.owner,
|
|
review_frequency_days=control.review_frequency_days,
|
|
status=control.status.value if control.status else None,
|
|
status_notes=control.status_notes,
|
|
status_justification=control.status_justification,
|
|
last_reviewed_at=control.last_reviewed_at,
|
|
next_review_at=control.next_review_at,
|
|
created_at=control.created_at,
|
|
updated_at=control.updated_at,
|
|
evidence_count=len(evidence),
|
|
)
|
|
|
|
|
|
@router.put("/controls/{control_id}", response_model=ControlResponse)
|
|
async def update_control(
|
|
control_id: str,
|
|
update: ControlUpdate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update a control."""
|
|
repo = ControlRepository(db)
|
|
control = repo.get_by_control_id(control_id)
|
|
if not control:
|
|
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
|
|
|
|
update_data = update.model_dump(exclude_unset=True)
|
|
|
|
# Convert status string to enum and validate transition
|
|
if "status" in update_data:
|
|
try:
|
|
new_status_enum = ControlStatusEnum(update_data["status"])
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid status: {update_data['status']}")
|
|
|
|
# Validate status transition (Anti-Fake-Evidence)
|
|
from ..services.control_status_machine import validate_transition
|
|
current_status = control.status.value if control.status else "planned"
|
|
evidence_list = db.query(EvidenceDB).filter(EvidenceDB.control_id == control.id).all()
|
|
allowed, violations = validate_transition(
|
|
current_status=current_status,
|
|
new_status=update_data["status"],
|
|
evidence_list=evidence_list,
|
|
status_justification=update_data.get("status_justification") or update_data.get("status_notes"),
|
|
)
|
|
if not allowed:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"error": "Status transition not allowed",
|
|
"current_status": current_status,
|
|
"requested_status": update_data["status"],
|
|
"violations": violations,
|
|
}
|
|
)
|
|
|
|
update_data["status"] = new_status_enum
|
|
|
|
updated = repo.update(control.id, **update_data)
|
|
db.commit()
|
|
|
|
# Audit trail for status changes
|
|
new_status = updated.status.value if updated.status else None
|
|
if "status" in update.model_dump(exclude_unset=True) and current_status != new_status:
|
|
log_audit_trail(
|
|
db, "control", control.id, updated.control_id or updated.title,
|
|
"status_change",
|
|
performed_by=update.owner or "system",
|
|
field_changed="status",
|
|
old_value=current_status,
|
|
new_value=new_status,
|
|
)
|
|
db.commit()
|
|
|
|
return ControlResponse(
|
|
id=updated.id,
|
|
control_id=updated.control_id,
|
|
domain=updated.domain.value if updated.domain else None,
|
|
control_type=updated.control_type.value if updated.control_type else None,
|
|
title=updated.title,
|
|
description=updated.description,
|
|
pass_criteria=updated.pass_criteria,
|
|
implementation_guidance=updated.implementation_guidance,
|
|
code_reference=updated.code_reference,
|
|
documentation_url=updated.documentation_url,
|
|
is_automated=updated.is_automated,
|
|
automation_tool=updated.automation_tool,
|
|
automation_config=updated.automation_config,
|
|
owner=updated.owner,
|
|
review_frequency_days=updated.review_frequency_days,
|
|
status=updated.status.value if updated.status else None,
|
|
status_notes=updated.status_notes,
|
|
status_justification=updated.status_justification,
|
|
last_reviewed_at=updated.last_reviewed_at,
|
|
next_review_at=updated.next_review_at,
|
|
created_at=updated.created_at,
|
|
updated_at=updated.updated_at,
|
|
)
|
|
|
|
|
|
@router.put("/controls/{control_id}/review", response_model=ControlResponse)
|
|
async def review_control(
|
|
control_id: str,
|
|
review: ControlReviewRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Mark a control as reviewed with new status."""
|
|
repo = ControlRepository(db)
|
|
control = repo.get_by_control_id(control_id)
|
|
if not control:
|
|
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
|
|
|
|
try:
|
|
status_enum = ControlStatusEnum(review.status)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid status: {review.status}")
|
|
|
|
updated = repo.mark_reviewed(control.id, status_enum, review.status_notes)
|
|
db.commit()
|
|
|
|
return ControlResponse(
|
|
id=updated.id,
|
|
control_id=updated.control_id,
|
|
domain=updated.domain.value if updated.domain else None,
|
|
control_type=updated.control_type.value if updated.control_type else None,
|
|
title=updated.title,
|
|
description=updated.description,
|
|
pass_criteria=updated.pass_criteria,
|
|
implementation_guidance=updated.implementation_guidance,
|
|
code_reference=updated.code_reference,
|
|
documentation_url=updated.documentation_url,
|
|
is_automated=updated.is_automated,
|
|
automation_tool=updated.automation_tool,
|
|
automation_config=updated.automation_config,
|
|
owner=updated.owner,
|
|
review_frequency_days=updated.review_frequency_days,
|
|
status=updated.status.value if updated.status else None,
|
|
status_notes=updated.status_notes,
|
|
status_justification=updated.status_justification,
|
|
last_reviewed_at=updated.last_reviewed_at,
|
|
next_review_at=updated.next_review_at,
|
|
created_at=updated.created_at,
|
|
updated_at=updated.updated_at,
|
|
)
|
|
|
|
|
|
@router.get("/controls/by-domain/{domain}", response_model=ControlListResponse)
|
|
async def get_controls_by_domain(domain: str, db: Session = Depends(get_db)):
|
|
"""Get controls by domain."""
|
|
try:
|
|
domain_enum = ControlDomainEnum(domain)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid domain: {domain}")
|
|
|
|
repo = ControlRepository(db)
|
|
controls = repo.get_by_domain(domain_enum)
|
|
|
|
results = [
|
|
ControlResponse(
|
|
id=c.id,
|
|
control_id=c.control_id,
|
|
domain=c.domain.value if c.domain else None,
|
|
control_type=c.control_type.value if c.control_type else None,
|
|
title=c.title,
|
|
description=c.description,
|
|
pass_criteria=c.pass_criteria,
|
|
implementation_guidance=c.implementation_guidance,
|
|
code_reference=c.code_reference,
|
|
documentation_url=c.documentation_url,
|
|
is_automated=c.is_automated,
|
|
automation_tool=c.automation_tool,
|
|
automation_config=c.automation_config,
|
|
owner=c.owner,
|
|
review_frequency_days=c.review_frequency_days,
|
|
status=c.status.value if c.status else None,
|
|
status_notes=c.status_notes,
|
|
last_reviewed_at=c.last_reviewed_at,
|
|
next_review_at=c.next_review_at,
|
|
created_at=c.created_at,
|
|
updated_at=c.updated_at,
|
|
)
|
|
for c in controls
|
|
]
|
|
|
|
return ControlListResponse(controls=results, total=len(results))
|
|
|
|
|
|
@router.post("/export", response_model=ExportResponse)
|
|
async def create_export(
|
|
request: ExportRequest,
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a new audit export."""
|
|
generator = AuditExportGenerator(db)
|
|
export = generator.create_export(
|
|
requested_by="api_user", # TODO: Get from auth
|
|
export_type=request.export_type,
|
|
included_regulations=request.included_regulations,
|
|
included_domains=request.included_domains,
|
|
date_range_start=request.date_range_start,
|
|
date_range_end=request.date_range_end,
|
|
)
|
|
|
|
return ExportResponse(
|
|
id=export.id,
|
|
export_type=export.export_type,
|
|
export_name=export.export_name,
|
|
status=export.status.value if export.status else None,
|
|
requested_by=export.requested_by,
|
|
requested_at=export.requested_at,
|
|
completed_at=export.completed_at,
|
|
file_path=export.file_path,
|
|
file_hash=export.file_hash,
|
|
file_size_bytes=export.file_size_bytes,
|
|
total_controls=export.total_controls,
|
|
total_evidence=export.total_evidence,
|
|
compliance_score=export.compliance_score,
|
|
error_message=export.error_message,
|
|
)
|
|
|
|
|
|
@router.get("/export/{export_id}", response_model=ExportResponse)
|
|
async def get_export(export_id: str, db: Session = Depends(get_db)):
|
|
"""Get export status."""
|
|
generator = AuditExportGenerator(db)
|
|
export = generator.get_export_status(export_id)
|
|
if not export:
|
|
raise HTTPException(status_code=404, detail=f"Export {export_id} not found")
|
|
|
|
return ExportResponse(
|
|
id=export.id,
|
|
export_type=export.export_type,
|
|
export_name=export.export_name,
|
|
status=export.status.value if export.status else None,
|
|
requested_by=export.requested_by,
|
|
requested_at=export.requested_at,
|
|
completed_at=export.completed_at,
|
|
file_path=export.file_path,
|
|
file_hash=export.file_hash,
|
|
file_size_bytes=export.file_size_bytes,
|
|
total_controls=export.total_controls,
|
|
total_evidence=export.total_evidence,
|
|
compliance_score=export.compliance_score,
|
|
error_message=export.error_message,
|
|
)
|
|
|
|
|
|
@router.get("/export/{export_id}/download")
|
|
async def download_export(export_id: str, db: Session = Depends(get_db)):
|
|
"""Download export file."""
|
|
generator = AuditExportGenerator(db)
|
|
export = generator.get_export_status(export_id)
|
|
if not export:
|
|
raise HTTPException(status_code=404, detail=f"Export {export_id} not found")
|
|
|
|
if export.status.value != "completed":
|
|
raise HTTPException(status_code=400, detail="Export not completed")
|
|
|
|
if not export.file_path or not os.path.exists(export.file_path):
|
|
raise HTTPException(status_code=404, detail="Export file not found")
|
|
|
|
return FileResponse(
|
|
export.file_path,
|
|
media_type="application/zip",
|
|
filename=os.path.basename(export.file_path),
|
|
)
|
|
|
|
|
|
@router.get("/exports", response_model=ExportListResponse)
|
|
async def list_exports(
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List recent exports."""
|
|
generator = AuditExportGenerator(db)
|
|
exports = generator.list_exports(limit, offset)
|
|
|
|
results = [
|
|
ExportResponse(
|
|
id=e.id,
|
|
export_type=e.export_type,
|
|
export_name=e.export_name,
|
|
status=e.status.value if e.status else None,
|
|
requested_by=e.requested_by,
|
|
requested_at=e.requested_at,
|
|
completed_at=e.completed_at,
|
|
file_path=e.file_path,
|
|
file_hash=e.file_hash,
|
|
file_size_bytes=e.file_size_bytes,
|
|
total_controls=e.total_controls,
|
|
total_evidence=e.total_evidence,
|
|
compliance_score=e.compliance_score,
|
|
error_message=e.error_message,
|
|
)
|
|
for e in exports
|
|
]
|
|
|
|
return ExportListResponse(exports=results, total=len(results))
|
|
|
|
|
|
# ============================================================================
|
|
# Seeding
|
|
# ============================================================================
|
|
|
|
@router.post("/init-tables")
|
|
async def init_tables(db: Session = Depends(get_db)):
|
|
"""Create compliance tables if they don't exist."""
|
|
from classroom_engine.database import engine
|
|
from ..db.models import (
|
|
RegulationDB, RequirementDB, ControlMappingDB,
|
|
RiskDB, AuditExportDB, AISystemDB
|
|
)
|
|
|
|
try:
|
|
# Create all tables
|
|
RegulationDB.__table__.create(engine, checkfirst=True)
|
|
RequirementDB.__table__.create(engine, checkfirst=True)
|
|
ControlDB.__table__.create(engine, checkfirst=True)
|
|
ControlMappingDB.__table__.create(engine, checkfirst=True)
|
|
EvidenceDB.__table__.create(engine, checkfirst=True)
|
|
RiskDB.__table__.create(engine, checkfirst=True)
|
|
AuditExportDB.__table__.create(engine, checkfirst=True)
|
|
AISystemDB.__table__.create(engine, checkfirst=True)
|
|
|
|
return {"success": True, "message": "Tables created successfully"}
|
|
except Exception as e:
|
|
logger.error(f"Table creation failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/create-indexes")
|
|
async def create_performance_indexes(db: Session = Depends(get_db)):
|
|
"""
|
|
Create additional performance indexes for large datasets.
|
|
|
|
These indexes are optimized for:
|
|
- Pagination queries (1000+ requirements)
|
|
- Full-text search
|
|
- Filtering by status/priority
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
indexes = [
|
|
# Priority index for sorting (descending, as we want high priority first)
|
|
("ix_req_priority_desc", "CREATE INDEX IF NOT EXISTS ix_req_priority_desc ON compliance_requirements (priority DESC)"),
|
|
|
|
# Compound index for common filtering patterns
|
|
("ix_req_applicable_status", "CREATE INDEX IF NOT EXISTS ix_req_applicable_status ON compliance_requirements (is_applicable, implementation_status)"),
|
|
|
|
# Control status index
|
|
("ix_ctrl_status", "CREATE INDEX IF NOT EXISTS ix_ctrl_status ON compliance_controls (status)"),
|
|
|
|
# Evidence collected_at for timeline queries
|
|
("ix_evidence_collected", "CREATE INDEX IF NOT EXISTS ix_evidence_collected ON compliance_evidence (collected_at DESC)"),
|
|
|
|
# Risk inherent risk level
|
|
("ix_risk_level", "CREATE INDEX IF NOT EXISTS ix_risk_level ON compliance_risks (inherent_risk)"),
|
|
]
|
|
|
|
created = []
|
|
errors = []
|
|
|
|
for idx_name, idx_sql in indexes:
|
|
try:
|
|
db.execute(text(idx_sql))
|
|
db.commit()
|
|
created.append(idx_name)
|
|
except Exception as e:
|
|
errors.append({"index": idx_name, "error": str(e)})
|
|
logger.warning(f"Index creation failed for {idx_name}: {e}")
|
|
|
|
return {
|
|
"success": len(errors) == 0,
|
|
"created": created,
|
|
"errors": errors,
|
|
"message": f"Created {len(created)} indexes" + (f", {len(errors)} failed" if errors else ""),
|
|
}
|
|
|
|
|
|
@router.post("/seed-risks")
|
|
async def seed_risks_only(db: Session = Depends(get_db)):
|
|
"""Seed only risks (incremental update for existing databases)."""
|
|
from classroom_engine.database import engine
|
|
from ..db.models import RiskDB
|
|
|
|
try:
|
|
# Ensure table exists
|
|
RiskDB.__table__.create(engine, checkfirst=True)
|
|
|
|
seeder = ComplianceSeeder(db)
|
|
count = seeder.seed_risks_only()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Successfully seeded {count} risks",
|
|
"risks_seeded": count,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Risk seeding failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/seed", response_model=SeedResponse)
|
|
async def seed_database(
|
|
request: SeedRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Seed the compliance database with initial data."""
|
|
from classroom_engine.database import engine
|
|
from ..db.models import (
|
|
RegulationDB, RequirementDB, ControlMappingDB,
|
|
RiskDB, AuditExportDB
|
|
)
|
|
|
|
try:
|
|
# Ensure tables exist first
|
|
RegulationDB.__table__.create(engine, checkfirst=True)
|
|
RequirementDB.__table__.create(engine, checkfirst=True)
|
|
ControlDB.__table__.create(engine, checkfirst=True)
|
|
ControlMappingDB.__table__.create(engine, checkfirst=True)
|
|
EvidenceDB.__table__.create(engine, checkfirst=True)
|
|
RiskDB.__table__.create(engine, checkfirst=True)
|
|
AuditExportDB.__table__.create(engine, checkfirst=True)
|
|
|
|
seeder = ComplianceSeeder(db)
|
|
counts = seeder.seed_all(force=request.force)
|
|
return SeedResponse(
|
|
success=True,
|
|
message="Database seeded successfully",
|
|
counts=counts,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Seeding failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|