fix(evidence): proxy download + fix Jira attachment signature
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Evidence download:
- Replace presigned MinIO URLs with backend proxy endpoint
  GET /api/v1/evidence/{id}/file streams the file through the backend
  so MinIO never needs to be publicly accessible from browsers
- Add download_file() helper to storage.py (internal boto3 get_object)
- download_url in EvidenceOut now points to the proxy endpoint

Jira attachment:
- Fix add_attachment call: use add_attachment_object(issue_key, BytesIO)
  instead of add_attachment(issue_key, filename=..., content=...) which
  had wrong keyword args for the installed atlassian-python-api version

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-28 11:26:01 +02:00
parent c886b6e8bb
commit 7111debd8f
2 changed files with 62 additions and 7 deletions

View File

@@ -4,7 +4,8 @@ Endpoints
--------- ---------
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue) POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
GET /tests/{test_id}/evidence — list evidences (filterable by team) GET /tests/{test_id}/evidence — list evidences (filterable by team)
GET /evidence/{id}presigned download URL GET /evidence/{id}metadata + download_url
GET /evidence/{id}/file — proxy download (streams file through backend)
DELETE /evidence/{id} — delete evidence (only in editable states) DELETE /evidence/{id} — delete evidence (only in editable states)
Access Control Access Control
@@ -20,12 +21,14 @@ Access Control
""" """
import hashlib import hashlib
import logging
import os import os
import uuid as _uuid import uuid as _uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
@@ -46,7 +49,9 @@ from app.services.evidence_service import (
validate_upload_permission, validate_upload_permission,
) )
from app.limiter import limiter from app.limiter import limiter
from app.storage import get_presigned_url, upload_file from app.storage import download_file, upload_file
logger = logging.getLogger(__name__)
router = APIRouter(tags=["evidence"]) router = APIRouter(tags=["evidence"])
@@ -56,7 +61,11 @@ router = APIRouter(tags=["evidence"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _evidence_to_out(evidence: Evidence) -> EvidenceOut: def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
"""Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL.""" """Convert an ORM ``Evidence`` to the API schema.
``download_url`` points to the backend proxy endpoint so the browser
never needs direct access to MinIO.
"""
return EvidenceOut( return EvidenceOut(
id=evidence.id, id=evidence.id,
test_id=evidence.test_id, test_id=evidence.test_id,
@@ -66,7 +75,7 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
uploaded_at=evidence.uploaded_at, uploaded_at=evidence.uploaded_at,
team=evidence.team, team=evidence.team,
notes=evidence.notes, notes=evidence.notes,
download_url=get_presigned_url(evidence.file_path), download_url=f"/api/v1/evidence/{evidence.id}/file",
) )
@@ -163,8 +172,11 @@ def _attach_evidence_to_jira(
issue_key = get_test_jira_key(db, test_id) issue_key = get_test_jira_key(db, test_id)
if not issue_key: if not issue_key:
return return
import io
jira = get_user_jira_client(actor, db) jira = get_user_jira_client(actor, db)
jira.add_attachment(issue_key, filename=file_name, content=content) buf = io.BytesIO(content)
buf.name = file_name # requests uses .name as the multipart filename
jira.add_attachment_object(issue_key, buf)
import logging import logging
logging.getLogger(__name__).info( logging.getLogger(__name__).info(
"Attached evidence '%s' to Jira ticket %s", file_name, issue_key "Attached evidence '%s' to Jira ticket %s", file_name, issue_key
@@ -195,7 +207,7 @@ def list_evidence(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /evidence/{id} — presigned download URL # GET /evidence/{id} — metadata + proxy download URL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -205,11 +217,48 @@ def get_evidence(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Return evidence metadata together with a presigned download URL.""" """Return evidence metadata. ``download_url`` is a backend proxy URL."""
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)
# ---------------------------------------------------------------------------
# GET /evidence/{id}/file — proxy download (streams file via backend)
# ---------------------------------------------------------------------------
@router.get("/evidence/{evidence_id}/file")
def download_evidence_file(
evidence_id: _uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Stream the evidence file through the backend.
The browser calls this endpoint (authenticated via JWT cookie/header).
The backend fetches the file from MinIO internally and streams it back,
so MinIO never needs to be publicly accessible.
"""
import mimetypes
evidence = get_evidence_or_raise(db, evidence_id)
content = download_file(evidence.file_path)
mime_type, _ = mimetypes.guess_type(evidence.file_name)
if not mime_type:
mime_type = "application/octet-stream"
safe_name = evidence.file_name.replace('"', '\\"')
return StreamingResponse(
iter([content]),
media_type=mime_type,
headers={
"Content-Disposition": f'inline; filename="{safe_name}"',
"Content-Length": str(len(content)),
},
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DELETE /evidence/{id} — delete evidence (editable states only) # DELETE /evidence/{id} — delete evidence (editable states only)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -70,6 +70,12 @@ def upload_file(content: bytes, key: str) -> str:
return key return key
def download_file(key: str) -> bytes:
"""Download *key* from the evidence bucket and return its raw bytes."""
response = _client.get_object(Bucket=settings.MINIO_BUCKET, Key=key)
return response["Body"].read()
def get_presigned_url(key: str, expiration: int = 3600) -> str: def get_presigned_url(key: str, expiration: int = 3600) -> str:
"""Return a presigned GET URL for *key* valid for *expiration* seconds. """Return a presigned GET URL for *key* valid for *expiration* seconds.