"""Evidence upload, download, listing and deletion router — v2 with Red/Blue separation. Endpoints --------- POST /tests/{test_id}/evidence — upload evidence (with team=red/blue) GET /tests/{test_id}/evidence — list evidences (filterable by team) GET /evidence/{id} — presigned download URL DELETE /evidence/{id} — delete evidence (only in editable states) Access Control -------------- - Red Team (``red_tech``) can only upload ``team=red`` when test is in ``draft`` or ``red_executing``. - Blue Team (``blue_tech``) can only upload ``team=blue`` when test is in ``blue_evaluating``. - Admin can upload any team in any state. - DELETE is restricted: red evidence in ``draft``/``red_executing``, blue evidence in ``blue_evaluating``. No deletions in ``in_review``, ``validated``, or ``rejected``. """ # Import hashlib import hashlib # Import os import os # Import uuid import uuid as _uuid # Import Optional from typing from typing import Optional # Import APIRouter, Depends, File, Form, Query, Request,... from fastapi from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import get_db from app.database from app.database import get_db # Import get_current_user from app.dependencies.auth from app.dependencies.auth import get_current_user # Import UnitOfWork from app.domain.unit_of_work from app.domain.unit_of_work import UnitOfWork # Import limiter from app.limiter from app.limiter import limiter # Import TeamSide from app.models.enums from app.models.enums import TeamSide # Import Evidence from app.models.evidence from app.models.evidence import Evidence # Import User from app.models.user from app.models.user import User # Import EvidenceOut from app.schemas.evidence from app.schemas.evidence import EvidenceOut # Import log_action from app.services.audit_service from app.services.audit_service import log_action # Import from app.services.evidence_service from app.services.evidence_service import ( MAX_UPLOAD_SIZE, get_evidence_or_raise, get_test_or_raise, list_evidence_for_test, validate_delete_permission, validate_file, validate_upload_permission, ) # Import get_presigned_url, upload_file from app.storage from app.storage import get_presigned_url, upload_file # Assign router = APIRouter(tags=["evidence"]) router = APIRouter(tags=["evidence"]) # --------------------------------------------------------------------------- # Helpers (router-specific: infrastructure / HTTP concerns) # --------------------------------------------------------------------------- def _evidence_to_out(evidence: Evidence) -> EvidenceOut: """Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL.""" # Return EvidenceOut( return EvidenceOut( # Keyword argument: id id=evidence.id, # Keyword argument: test_id test_id=evidence.test_id, # Keyword argument: file_name file_name=evidence.file_name, # Keyword argument: sha256_hash sha256_hash=evidence.sha256_hash, # Keyword argument: uploaded_by uploaded_by=evidence.uploaded_by, # Keyword argument: uploaded_at uploaded_at=evidence.uploaded_at, # Keyword argument: team team=evidence.team, # Keyword argument: notes notes=evidence.notes, # Keyword argument: download_url download_url=get_presigned_url(evidence.file_path), ) # --------------------------------------------------------------------------- # POST /tests/{test_id}/evidence — upload with team # --------------------------------------------------------------------------- @router.post( # Literal argument value "/tests/{test_id}/evidence", # Keyword argument: response_model response_model=EvidenceOut, # Keyword argument: status_code status_code=status.HTTP_201_CREATED, ) # Apply the @limiter.limit decorator @limiter.limit("10/minute") # Define async function upload_evidence async def upload_evidence( # Entry: request request: Request, # Entry: test_id test_id: _uuid.UUID, # Entry: file file: UploadFile = File(...), # Entry: team team: TeamSide = Form(TeamSide.red), # Entry: notes notes: Optional[str] = Form(None), # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> EvidenceOut: """Upload a file as evidence for the given test. The ``team`` field (sent as form data) determines whether this is Red Team (attack) or Blue Team (detection) evidence. """ # Assign test = get_test_or_raise(db, test_id) test = get_test_or_raise(db, test_id) # Call validate_upload_permission() validate_upload_permission(test, team, current_user.role) # Assign file_name = file.filename or "unnamed" file_name = file.filename or "unnamed" # Assign content = await file.read(MAX_UPLOAD_SIZE + 1) content = await file.read(MAX_UPLOAD_SIZE + 1) # Call validate_file() validate_file(file_name, len(content)) # Hash sha256 = hashlib.sha256(content).hexdigest() # 4. Object key (sanitise filename to prevent path traversal in storage) safe_name = os.path.basename(file_name) # Assign key = f"{test_id}/{_uuid.uuid4()}_{safe_name}" key = f"{test_id}/{_uuid.uuid4()}_{safe_name}" # 5. Upload to MinIO upload_file(content, key) # 6. Persist metadata and audit with UnitOfWork(db) as uow: # Assign evidence = Evidence( evidence = Evidence( # Keyword argument: test_id test_id=test_id, # Keyword argument: file_name file_name=safe_name, # Keyword argument: file_path file_path=key, # Keyword argument: sha256_hash sha256_hash=sha256, # Keyword argument: uploaded_by uploaded_by=current_user.id, # Keyword argument: team team=team, # Keyword argument: notes notes=notes, ) # Stage new record(s) for database insertion db.add(evidence) # Flush changes to DB without committing the transaction db.flush() # Get evidence.id for audit # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="upload_evidence", # Keyword argument: entity_type entity_type="evidence", # Keyword argument: entity_id entity_id=evidence.id, # Keyword argument: details details={ # Literal argument value "file_name": safe_name, # Literal argument value "sha256": sha256, # Literal argument value "test_id": str(test_id), # Literal argument value "team": team.value, }, ) # Call uow.commit() uow.commit() # Reload ORM object attributes from the database db.refresh(evidence) # Return _evidence_to_out(evidence) return _evidence_to_out(evidence) # --------------------------------------------------------------------------- # GET /tests/{test_id}/evidence — list (with optional team filter) # --------------------------------------------------------------------------- @router.get("/tests/{test_id}/evidence", response_model=list[EvidenceOut]) # Define function list_evidence def list_evidence( # Entry: test_id test_id: _uuid.UUID, # Entry: team team: Optional[str] = Query(None, description="Filter by team: red or blue"), # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> list[EvidenceOut]: """List all evidences for a test, optionally filtered by team.""" # Call get_test_or_raise() get_test_or_raise(db, test_id) # Assign evidences = list_evidence_for_test(db, test_id, team=team) evidences = list_evidence_for_test(db, test_id, team=team) # Return [_evidence_to_out(e) for e in evidences] return [_evidence_to_out(e) for e in evidences] # --------------------------------------------------------------------------- # GET /evidence/{id} — presigned download URL # --------------------------------------------------------------------------- @router.get("/evidence/{evidence_id}", response_model=EvidenceOut) # Define function get_evidence def get_evidence( # Entry: evidence_id evidence_id: _uuid.UUID, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> EvidenceOut: """Return evidence metadata together with a presigned download URL.""" # Assign evidence = get_evidence_or_raise(db, evidence_id) evidence = get_evidence_or_raise(db, evidence_id) # Return _evidence_to_out(evidence) return _evidence_to_out(evidence) # --------------------------------------------------------------------------- # DELETE /evidence/{id} — delete evidence (editable states only) # --------------------------------------------------------------------------- @router.delete("/evidence/{evidence_id}", status_code=status.HTTP_200_OK) # Define function delete_evidence def delete_evidence( # Entry: evidence_id evidence_id: _uuid.UUID, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> dict: """Delete an evidence record. Only allowed in editable states: - Red evidence: ``draft``, ``red_executing`` - Blue evidence: ``blue_evaluating`` - No deletions in ``in_review``, ``validated``, ``rejected`` """ # Assign evidence = get_evidence_or_raise(db, evidence_id) evidence = get_evidence_or_raise(db, evidence_id) # Assign test = get_test_or_raise(db, evidence.test_id) test = get_test_or_raise(db, evidence.test_id) # Call validate_delete_permission() validate_delete_permission(test, evidence, current_user.role, current_user.id) # Open context manager with UnitOfWork(db) as uow: # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="delete_evidence", # Keyword argument: entity_type entity_type="evidence", # Keyword argument: entity_id entity_id=evidence.id, # Keyword argument: details details={ # Literal argument value "file_name": evidence.file_name, # Literal argument value "test_id": str(evidence.test_id), # Literal argument value "team": evidence.team.value if evidence.team else None, }, ) # Mark record for deletion on next commit db.delete(evidence) # Call uow.commit() uow.commit() # Return {"detail": "Evidence deleted"} return {"detail": "Evidence deleted"}