diff --git a/backend/app/config.py b/backend/app/config.py index 2d2d701..cac36cc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -36,6 +36,10 @@ class Settings(BaseSettings): # ── MinIO / S3 ─────────────────────────────────────────────────── MINIO_ENDPOINT: str = "minio:9000" + # Public hostname used in presigned URLs returned to browsers. + # In production set this to :9000 (or a public FQDN) so + # the browser can reach MinIO directly. Defaults to MINIO_ENDPOINT. + MINIO_PUBLIC_ENDPOINT: str = "" MINIO_ACCESS_KEY: str = "minioadmin" MINIO_SECRET_KEY: str = "minioadmin" MINIO_BUCKET: str = "evidence" diff --git a/backend/app/routers/evidence.py b/backend/app/routers/evidence.py index b46a2b2..b000092 100644 --- a/backend/app/routers/evidence.py +++ b/backend/app/routers/evidence.py @@ -22,6 +22,7 @@ Access Control import hashlib import os import uuid as _uuid +from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status @@ -119,6 +120,7 @@ async def upload_evidence( file_path=key, sha256_hash=sha256, uploaded_by=current_user.id, + uploaded_at=datetime.utcnow(), # set explicitly — DB column has no server default team=team, notes=notes, ) @@ -140,9 +142,40 @@ async def upload_evidence( uow.commit() db.refresh(evidence) + # 7. Attach to Jira ticket if one exists (non-fatal) + _attach_evidence_to_jira(db, test_id, content, safe_name, current_user) + return _evidence_to_out(evidence) +def _attach_evidence_to_jira( + db, + test_id: _uuid.UUID, + content: bytes, + file_name: str, + actor, +) -> None: + """Attach uploaded evidence to the linked Jira ticket (non-fatal).""" + try: + from app.services.jira_service import get_test_jira_key, get_user_jira_client, has_jira_configured + if not has_jira_configured(actor, db): + return + issue_key = get_test_jira_key(db, test_id) + if not issue_key: + return + jira = get_user_jira_client(actor, db) + jira.add_attachment(issue_key, filename=file_name, content=content) + import logging + logging.getLogger(__name__).info( + "Attached evidence '%s' to Jira ticket %s", file_name, issue_key + ) + except Exception as exc: + import logging + logging.getLogger(__name__).warning( + "Failed to attach evidence '%s' to Jira: %s", file_name, exc, exc_info=True + ) + + # --------------------------------------------------------------------------- # GET /tests/{test_id}/evidence — list (with optional team filter) # --------------------------------------------------------------------------- diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 18328bb..2e1fb2e 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -488,12 +488,15 @@ def auto_create_test_issue( parent = parent_ticket_override or get_jira_parent_ticket_standalone(db) issue_type = settings.JIRA_ISSUE_TYPE_TEST # always Task + poc = test.procedure_text or "N/A" fields: dict = { "project": {"key": project_key}, "summary": f"[Aegis] {mitre_id} — {test.name}", "description": _build_test_description(test, technique), "issuetype": {"name": issue_type}, "labels": ["aegis", "security-test", mitre_id.replace(".", "-")], + # customfield_10309 = Proof of Concept field (required by team's Jira config) + "customfield_10309": f"{{code}}{poc}{{code}}", } if parent: diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index e3cdebc..7c36b69 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -29,8 +29,10 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType logger = logging.getLogger(__name__) -# Activity types forwarded to Tempo. -_TEMPO_ACTIVITY_TYPES = {"red_team_execution", "blue_team_evaluation"} +# Only red team execution time goes to Tempo. +# Blue team evaluation time is tracked internally (worklogs table) for SLA +# purposes but is NOT forwarded to Tempo — blue team has no Jira access. +_TEMPO_ACTIVITY_TYPES = {"red_team_execution"} def has_tempo_configured(user) -> bool: diff --git a/backend/app/storage.py b/backend/app/storage.py index 694c63b..4d085c6 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -2,6 +2,14 @@ Provides thin wrappers around boto3 for bucket management, file upload and presigned-URL generation. + +Two clients are maintained: +- ``_client`` — uses the internal Docker endpoint (``MINIO_ENDPOINT``) for + all server-side operations (upload, head_bucket, create_bucket). +- ``_public_client`` — used **only** for presigned URL generation. It uses + ``MINIO_PUBLIC_ENDPOINT`` when set so the resulting URLs are reachable from + browsers. If ``MINIO_PUBLIC_ENDPOINT`` is not configured it falls back to + ``MINIO_ENDPOINT`` (backwards-compatible). """ import boto3 @@ -10,11 +18,12 @@ from botocore.exceptions import ClientError from app.config import settings # --------------------------------------------------------------------------- -# Shared client (module-level singleton) +# Shared clients (module-level singletons) # --------------------------------------------------------------------------- _scheme = "https" if settings.MINIO_SECURE else "http" +# Internal client — used for uploads and bucket management _client = boto3.client( "s3", endpoint_url=f"{_scheme}://{settings.MINIO_ENDPOINT}", @@ -23,6 +32,17 @@ _client = boto3.client( region_name="us-east-1", # MinIO ignores this but boto3 requires it ) +# Public client — used only for presigned URL generation so URLs contain the +# publicly accessible hostname instead of the internal Docker service name. +_public_endpoint = settings.MINIO_PUBLIC_ENDPOINT or settings.MINIO_ENDPOINT +_public_client = boto3.client( + "s3", + endpoint_url=f"{_scheme}://{_public_endpoint}", + aws_access_key_id=settings.MINIO_ACCESS_KEY, + aws_secret_access_key=settings.MINIO_SECRET_KEY, + region_name="us-east-1", +) + # --------------------------------------------------------------------------- # Public helpers @@ -51,8 +71,13 @@ def upload_file(content: bytes, key: str) -> str: def get_presigned_url(key: str, expiration: int = 3600) -> str: - """Return a presigned GET URL for *key* valid for *expiration* seconds.""" - return _client.generate_presigned_url( + """Return a presigned GET URL for *key* valid for *expiration* seconds. + + Uses ``_public_client`` so the URL contains the publicly accessible + hostname (``MINIO_PUBLIC_ENDPOINT``) rather than the internal Docker + service name (``MINIO_ENDPOINT``). + """ + return _public_client.generate_presigned_url( "get_object", Params={"Bucket": settings.MINIO_BUCKET, "Key": key}, ExpiresIn=expiration, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ecba788..2a4627d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -38,6 +38,10 @@ services: MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} volumes: - minio_data:/data + ports: + # Expose MinIO API so browsers can access presigned evidence URLs. + # Override with MINIO_PORT env var if port 9000 is already in use. + - "${MINIO_PORT:-9000}:9000" healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 5s @@ -75,6 +79,9 @@ services: ALGORITHM: HS256 ACCESS_TOKEN_EXPIRE_MINUTES: ${TOKEN_EXPIRE_MINUTES:-60} MINIO_ENDPOINT: minio:9000 + # Public hostname browsers use to fetch presigned evidence URLs. + # Set to :9000 (or a public FQDN) in your .env file. + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} MINIO_BUCKET: ${MINIO_BUCKET:-evidence} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index fa3e237..80cc8d8 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -810,6 +810,9 @@ function NotificationSection() { function ProfileSection() { const qc = useQueryClient(); const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); + const { user } = useAuth(); + // Blue team roles have no Jira/Tempo access — hide those settings for them + const showJiraTempoSettings = !["blue_lead", "blue_tech"].includes(user?.role ?? ""); const { data: me, isLoading } = useQuery({ queryKey: ["me-prefs"], @@ -927,7 +930,7 @@ function ProfileSection() { -
+ {showJiraTempoSettings &&

Jira Integration (personal settings)

Configure your personal Atlassian credentials for Jira integration. @@ -1020,10 +1023,10 @@ function ProfileSection() {

- + } {/* ── Tempo Integration ─────────────────────────────────── */} -
+ {showJiraTempoSettings &&

Tempo Integration (personal settings)

Your personal Tempo API token logs work time on Jira tickets automatically. @@ -1119,7 +1122,7 @@ function ProfileSection() {

- + } );