Files
breakpilot-compliance/backend-compliance/compliance/api/evidence_routes.py
Sharang Parnerkar a638d0e527 refactor(backend/api): extract EvidenceService (Step 4 — file 9 of 18)
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>
2026-04-08 21:59:03 +02:00

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",
]