Files
Aegis/backend/app/routers/evidence.py
T
kitos 394d5d9056 refactor(types): add comprehensive type annotations across backend Python codebase
Enable ANN rules in ruff.toml (flake8-annotations) and resolve all 221 violations:

ANN201/ANN202 — return types on 168 public/private functions:
- All 28 FastAPI routers: endpoints annotated with dict/list/specific schema/
  StreamingResponse/FileResponse/JSONResponse as appropriate
- main.py: lifespan→AsyncGenerator[None,None], exception handlers→JSONResponse
- database.py: get_db→Generator[Session,None,None], proxy methods→correct types
- middleware/request_context.py: dispatch→Response with Callable call_next type

ANN001/ANN002/ANN003 — 32 missing argument types:
- seed_demo.py: all db parameters typed as Session
- domain/unit_of_work.py: __aexit__ exc_type/exc_val/exc_tb typed with TracebackType
- services: audit_service user_id→UUID|None, heatmap_service query/model/builder,
  notification_service test→Test, tempo_service test→Test/user→User,
  test_workflow_service test_id→UUID, campaign_crud **fields→object,
  test_crud **fields→object (4 sites)

ANN401 — 16 Any usages resolved:
- Domain entities (campaign/technique/threat_actor/test_entity): replaced Any with
  actual ORM types via TYPE_CHECKING guards to avoid circular imports
- detection_rule_service: test_id/detection_rule_id/evaluator_id→UUID
- score_cache: kept Any with # noqa: ANN401 (genuinely generic cache)
- jira_service/tempo_service: kept Any with # noqa: ANN401 (lazy optional deps)
- d3fend_import_service: _to_str(v: Any) kept with # noqa: ANN401

ANN204/ANN205/ANN206 — special/static/class methods:
- database.py proxy __call__/__getattr__: *args: object/**kwargs: object
- schemas/test.py model_validate: obj→object, **kwargs→object
- sa_technique_repository._int_type→type

All 439 unit tests pass. ruff check app/ → All checks passed!

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:04:51 +02:00

219 lines
7.3 KiB
Python

"""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 os
import uuid as _uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.domain.unit_of_work import UnitOfWork
from app.limiter import limiter
from app.models.enums import TeamSide
from app.models.evidence import Evidence
from app.models.user import User
from app.schemas.evidence import EvidenceOut
from app.services.audit_service import log_action
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,
)
from app.storage import get_presigned_url, upload_file
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(
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),
)
# ---------------------------------------------------------------------------
# POST /tests/{test_id}/evidence — upload with team
# ---------------------------------------------------------------------------
@router.post(
"/tests/{test_id}/evidence",
response_model=EvidenceOut,
status_code=status.HTTP_201_CREATED,
)
@limiter.limit("10/minute")
async def upload_evidence(
request: Request,
test_id: _uuid.UUID,
file: UploadFile = File(...),
team: TeamSide = Form(TeamSide.red),
notes: Optional[str] = Form(None),
db: Session = Depends(get_db),
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.
"""
test = get_test_or_raise(db, test_id)
validate_upload_permission(test, team, current_user.role)
file_name = file.filename or "unnamed"
content = await file.read(MAX_UPLOAD_SIZE + 1)
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)
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:
evidence = Evidence(
test_id=test_id,
file_name=safe_name,
file_path=key,
sha256_hash=sha256,
uploaded_by=current_user.id,
team=team,
notes=notes,
)
db.add(evidence)
db.flush() # Get evidence.id for audit
log_action(
db,
user_id=current_user.id,
action="upload_evidence",
entity_type="evidence",
entity_id=evidence.id,
details={
"file_name": safe_name,
"sha256": sha256,
"test_id": str(test_id),
"team": team.value,
},
)
uow.commit()
db.refresh(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])
def list_evidence(
test_id: _uuid.UUID,
team: Optional[str] = Query(None, description="Filter by team: red or blue"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[EvidenceOut]:
"""List all evidences for a test, optionally filtered by team."""
get_test_or_raise(db, test_id)
evidences = list_evidence_for_test(db, test_id, team=team)
return [_evidence_to_out(e) for e in evidences]
# ---------------------------------------------------------------------------
# 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),
) -> EvidenceOut:
"""Return evidence metadata together with a presigned download URL."""
evidence = get_evidence_or_raise(db, evidence_id)
return _evidence_to_out(evidence)
# ---------------------------------------------------------------------------
# DELETE /evidence/{id} — delete evidence (editable states only)
# ---------------------------------------------------------------------------
@router.delete("/evidence/{evidence_id}", status_code=status.HTTP_200_OK)
def delete_evidence(
evidence_id: _uuid.UUID,
db: Session = Depends(get_db),
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``
"""
evidence = get_evidence_or_raise(db, evidence_id)
test = get_test_or_raise(db, evidence.test_id)
validate_delete_permission(test, evidence, current_user.role, current_user.id)
with UnitOfWork(db) as uow:
log_action(
db,
user_id=current_user.id,
action="delete_evidence",
entity_type="evidence",
entity_id=evidence.id,
details={
"file_name": evidence.file_name,
"test_id": str(evidence.test_id),
"team": evidence.team.value if evidence.team else None,
},
)
db.delete(evidence)
uow.commit()
return {"detail": "Evidence deleted"}