compliance/api/evidence_routes.py (641 LOC) -> 240 LOC thin routes + 460-line
EvidenceService. Manages evidence CRUD, file upload, CI/CD evidence
collection (SAST/dependency/SBOM/container scans), and CI status dashboard.
Service injection pattern: EvidenceService takes the EvidenceRepository,
ControlRepository, and AutoRiskUpdater classes as constructor parameters.
The route's get_evidence_service factory reads these class references from
its own module namespace so tests that
``patch("compliance.api.evidence_routes.EvidenceRepository", ...)`` still
take effect through the factory.
The `_store_evidence` and `_update_risks` helpers stay as module-level
callables in evidence_service and are re-exported from the route module.
The collect_ci_evidence handler remains inline (not delegated to a service
method) so tests can patch
`compliance.api.evidence_routes._store_evidence` and have the patch take
effect at the handler's call site.
Legacy re-exports via __all__: SOURCE_CONTROL_MAP, EvidenceRepository,
ControlRepository, AutoRiskUpdater, _parse_ci_evidence,
_extract_findings_detail, _store_evidence, _update_risks.
Verified:
- 208/208 pytest (core + 35 evidence tests) pass
- OpenAPI 360/484 unchanged
- mypy compliance/ -> Success on 135 source files
- evidence_routes.py 641 -> 240 LOC
- Hard-cap violations: 10 -> 9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
241 lines
8.1 KiB
Python
241 lines
8.1 KiB
Python
# mypy: disable-error-code="arg-type"
|
|
"""
|
|
FastAPI routes for Evidence management.
|
|
|
|
Endpoints:
|
|
- /evidence: Evidence listing and creation
|
|
- /evidence/upload: Evidence file upload
|
|
- /evidence/collect: CI/CD evidence collection
|
|
- /evidence/ci-status: CI/CD evidence status
|
|
|
|
Phase 1 Step 4 refactor: handlers delegate to EvidenceService. Pure
|
|
helpers (`_parse_ci_evidence`, `_extract_findings_detail`) and the
|
|
SOURCE_CONTROL_MAP constant are re-exported from this module so the
|
|
existing tests (tests/test_evidence_routes.py) continue to import them
|
|
from the legacy path.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from compliance.api._http_errors import translate_domain_errors
|
|
from compliance.db import ControlRepository, EvidenceRepository
|
|
from compliance.schemas.evidence import (
|
|
EvidenceCreate,
|
|
EvidenceListResponse,
|
|
EvidenceResponse,
|
|
)
|
|
from compliance.services.auto_risk_updater import AutoRiskUpdater
|
|
from compliance.domain import NotFoundError, ValidationError
|
|
from compliance.services.evidence_service import (
|
|
SOURCE_CONTROL_MAP,
|
|
EvidenceService,
|
|
_extract_findings_detail, # re-exported for legacy test imports
|
|
_parse_ci_evidence, # re-exported for legacy test imports
|
|
_store_evidence, # re-exported for legacy test imports
|
|
_update_risks as _update_risks_impl,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["compliance-evidence"])
|
|
|
|
|
|
def get_evidence_service(db: Session = Depends(get_db)) -> EvidenceService:
|
|
# Read repo + auto-updater classes from this module's namespace at call
|
|
# time so test patches against compliance.api.evidence_routes.* propagate.
|
|
return EvidenceService(
|
|
db,
|
|
evidence_repo_cls=EvidenceRepository,
|
|
control_repo_cls=ControlRepository,
|
|
auto_updater_cls=AutoRiskUpdater,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Evidence CRUD
|
|
# ============================================================================
|
|
|
|
@router.get("/evidence", response_model=EvidenceListResponse)
|
|
async def list_evidence(
|
|
control_id: Optional[str] = None,
|
|
evidence_type: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"),
|
|
limit: Optional[int] = Query(None, ge=1, le=500, description="Items per page"),
|
|
service: EvidenceService = Depends(get_evidence_service),
|
|
) -> EvidenceListResponse:
|
|
"""List evidence with optional filters and pagination."""
|
|
with translate_domain_errors():
|
|
return service.list_evidence(control_id, evidence_type, status, page, limit)
|
|
|
|
|
|
@router.post("/evidence", response_model=EvidenceResponse)
|
|
async def create_evidence(
|
|
evidence_data: EvidenceCreate,
|
|
service: EvidenceService = Depends(get_evidence_service),
|
|
) -> EvidenceResponse:
|
|
"""Create new evidence record."""
|
|
with translate_domain_errors():
|
|
return service.create_evidence(evidence_data)
|
|
|
|
|
|
@router.delete("/evidence/{evidence_id}")
|
|
async def delete_evidence(
|
|
evidence_id: str,
|
|
service: EvidenceService = Depends(get_evidence_service),
|
|
) -> dict[str, Any]:
|
|
"""Delete an evidence record."""
|
|
with translate_domain_errors():
|
|
return service.delete_evidence(evidence_id)
|
|
|
|
|
|
# ============================================================================
|
|
# Upload
|
|
# ============================================================================
|
|
|
|
@router.post("/evidence/upload")
|
|
async def upload_evidence(
|
|
control_id: str = Query(...),
|
|
evidence_type: str = Query(...),
|
|
title: str = Query(...),
|
|
file: UploadFile = File(...),
|
|
description: Optional[str] = Query(None),
|
|
service: EvidenceService = Depends(get_evidence_service),
|
|
) -> EvidenceResponse:
|
|
"""Upload evidence file."""
|
|
with translate_domain_errors():
|
|
return await service.upload_evidence(
|
|
control_id, evidence_type, title, file, description
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# CI/CD Evidence Collection
|
|
# ============================================================================
|
|
|
|
def _update_risks(
|
|
db: Session,
|
|
*,
|
|
source: str,
|
|
control_id: str,
|
|
ci_job_id: Optional[str],
|
|
report_data: Optional[dict[str, Any]],
|
|
) -> Any:
|
|
"""Thin wrapper so test patches against this module's _update_risks take effect."""
|
|
return _update_risks_impl(
|
|
db,
|
|
source=source,
|
|
control_id=control_id,
|
|
ci_job_id=ci_job_id,
|
|
report_data=report_data,
|
|
auto_updater_cls=AutoRiskUpdater,
|
|
)
|
|
|
|
|
|
@router.post("/evidence/collect")
|
|
async def collect_ci_evidence(
|
|
source: str = Query(
|
|
...,
|
|
description="Evidence source: sast, dependency_scan, sbom, container_scan, test_results",
|
|
),
|
|
ci_job_id: Optional[str] = Query(None, description="CI/CD Job ID for traceability"),
|
|
ci_job_url: Optional[str] = Query(None, description="URL to CI/CD job"),
|
|
report_data: Optional[dict[str, Any]] = None,
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Collect evidence from CI/CD pipeline.
|
|
|
|
Handler stays inline so tests can patch
|
|
``compliance.api.evidence_routes._store_evidence`` /
|
|
``compliance.api.evidence_routes._update_risks`` directly.
|
|
"""
|
|
if source not in SOURCE_CONTROL_MAP:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unknown source '{source}'. Supported: {list(SOURCE_CONTROL_MAP.keys())}",
|
|
)
|
|
|
|
control_id = SOURCE_CONTROL_MAP[source]
|
|
ctrl_repo = ControlRepository(db)
|
|
control = ctrl_repo.get_by_control_id(control_id)
|
|
if not control:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Control {control_id} not found. Please seed the database first.",
|
|
)
|
|
|
|
parsed = _parse_ci_evidence(report_data or {})
|
|
evidence = _store_evidence(
|
|
db,
|
|
control_db_id=control.id,
|
|
source=source,
|
|
parsed=parsed,
|
|
ci_job_id=ci_job_id,
|
|
ci_job_url=ci_job_url,
|
|
report_data=report_data,
|
|
)
|
|
risk_update_result = _update_risks(
|
|
db,
|
|
source=source,
|
|
control_id=control_id,
|
|
ci_job_id=ci_job_id,
|
|
report_data=report_data,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"evidence_id": evidence.id,
|
|
"control_id": control_id,
|
|
"source": source,
|
|
"status": parsed["evidence_status"],
|
|
"findings_count": parsed["findings_count"],
|
|
"critical_findings": parsed["critical_findings"],
|
|
"artifact_path": evidence.artifact_path,
|
|
"message": f"Evidence collected successfully for control {control_id}",
|
|
"auto_risk_update": (
|
|
{
|
|
"enabled": True,
|
|
"control_updated": risk_update_result.control_updated,
|
|
"old_status": risk_update_result.old_status,
|
|
"new_status": risk_update_result.new_status,
|
|
"risks_affected": risk_update_result.risks_affected,
|
|
"alerts_generated": risk_update_result.alerts_generated,
|
|
}
|
|
if risk_update_result
|
|
else {"enabled": False, "error": "Auto-update skipped"}
|
|
),
|
|
}
|
|
|
|
|
|
@router.get("/evidence/ci-status")
|
|
async def get_ci_evidence_status(
|
|
control_id: Optional[str] = Query(None, description="Filter by control ID"),
|
|
days: int = Query(30, description="Look back N days"),
|
|
service: EvidenceService = Depends(get_evidence_service),
|
|
) -> dict[str, Any]:
|
|
"""Get CI/CD evidence collection status overview."""
|
|
with translate_domain_errors():
|
|
return service.ci_status(control_id, days)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Legacy re-exports for tests that import helpers directly.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
__all__ = [
|
|
"router",
|
|
"SOURCE_CONTROL_MAP",
|
|
"EvidenceRepository",
|
|
"ControlRepository",
|
|
"AutoRiskUpdater",
|
|
"_parse_ci_evidence",
|
|
"_extract_findings_detail",
|
|
"_store_evidence",
|
|
"_update_risks",
|
|
]
|