T-106: Create test_workflow_service.py with state-machine transitions for the complete test lifecycle (draft -> red_executing -> blue_evaluating -> in_review -> validated/rejected), dual validation by Red/Blue leads, and reopen capability with field cleanup. T-107: Update status_service.py to use detection_result from Blue Team instead of legacy result field, and differentiate between partial progress (some validated) vs all-in-progress states. T-108: Create atomic_import_service.py that downloads the Atomic Red Team repo as a ZIP (avoiding API rate limits), parses all atomics YAML files, and creates idempotent TestTemplate records mapped to MITRE techniques. Includes validation tests for all three tasks (19 checks total).
135 lines
3.9 KiB
Python
135 lines
3.9 KiB
Python
"""Evidence upload and download router."""
|
|
|
|
import hashlib
|
|
import uuid as _uuid
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.dependencies.auth import get_current_user
|
|
from app.models.evidence import Evidence
|
|
from app.models.test import Test
|
|
from app.models.user import User
|
|
from app.schemas.evidence import EvidenceOut
|
|
from app.services.audit_service import log_action
|
|
from app.storage import get_presigned_url, upload_file
|
|
|
|
router = APIRouter(tags=["evidence"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{test_id}/evidence — upload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/tests/{test_id}/evidence",
|
|
response_model=EvidenceOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def upload_evidence(
|
|
test_id: _uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Upload a file as evidence for the given test.
|
|
|
|
Steps:
|
|
1. Read file content and compute SHA-256.
|
|
2. Build an object key ``{test_id}/{uuid}_{filename}``.
|
|
3. Upload to MinIO.
|
|
4. Persist an :class:`Evidence` row in the database.
|
|
5. Write an audit-log entry.
|
|
"""
|
|
# Verify the parent test exists
|
|
test = db.query(Test).filter(Test.id == test_id).first()
|
|
if test is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Test not found",
|
|
)
|
|
|
|
# 1. Read content + hash
|
|
content = await file.read()
|
|
sha256 = hashlib.sha256(content).hexdigest()
|
|
|
|
# 2. Object key
|
|
file_name = file.filename or "unnamed"
|
|
key = f"{test_id}/{_uuid.uuid4()}_{file_name}"
|
|
|
|
# 3. Upload to MinIO
|
|
upload_file(content, key)
|
|
|
|
# 4. Persist metadata
|
|
evidence = Evidence(
|
|
test_id=test_id,
|
|
file_name=file_name,
|
|
file_path=key,
|
|
sha256_hash=sha256,
|
|
uploaded_by=current_user.id,
|
|
)
|
|
db.add(evidence)
|
|
db.commit()
|
|
db.refresh(evidence)
|
|
|
|
# 5. Audit
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="upload_evidence",
|
|
entity_type="evidence",
|
|
entity_id=evidence.id,
|
|
details={
|
|
"file_name": file_name,
|
|
"sha256": sha256,
|
|
"test_id": str(test_id),
|
|
},
|
|
)
|
|
|
|
# Build response with download URL
|
|
return _evidence_to_out(evidence)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /evidence/{id} — presigned download URL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/evidence/{evidence_id}", response_model=EvidenceOut)
|
|
def get_evidence(
|
|
evidence_id: _uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return evidence metadata together with a presigned download URL."""
|
|
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
|
|
if evidence is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Evidence not found",
|
|
)
|
|
|
|
return _evidence_to_out(evidence)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
|
"""Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL."""
|
|
return EvidenceOut(
|
|
id=evidence.id,
|
|
test_id=evidence.test_id,
|
|
file_name=evidence.file_name,
|
|
sha256_hash=evidence.sha256_hash,
|
|
uploaded_by=evidence.uploaded_by,
|
|
uploaded_at=evidence.uploaded_at,
|
|
team=evidence.team,
|
|
notes=evidence.notes,
|
|
download_url=get_presigned_url(evidence.file_path),
|
|
)
|