168 lines
5.7 KiB
Python
168 lines
5.7 KiB
Python
"""Evidence service — permission validation, file validation, and query logic.
|
|
|
|
Framework-agnostic; uses domain exceptions from app.domain.errors.
|
|
The router is responsible for HTTP concerns, file I/O, MinIO upload,
|
|
audit logging, and response formatting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import uuid
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.domain.errors import (
|
|
BusinessRuleViolation,
|
|
EntityNotFoundError,
|
|
PermissionViolation,
|
|
)
|
|
from app.models.enums import TeamSide, TestState
|
|
from app.models.evidence import Evidence
|
|
from app.models.test import Test
|
|
|
|
# States where red evidence can be uploaded / deleted
|
|
RED_EDITABLE_STATES = (TestState.draft, TestState.red_executing)
|
|
# States where blue evidence can be uploaded / deleted
|
|
BLUE_EDITABLE_STATES = (TestState.blue_evaluating,)
|
|
|
|
# Maximum upload size in bytes (50 MB)
|
|
MAX_UPLOAD_SIZE = 50 * 1024 * 1024
|
|
|
|
# Allowed file extensions (lowercase, with leading dot)
|
|
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
|
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg",
|
|
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt",
|
|
".md", ".rtf", ".odt", ".ods",
|
|
".log", ".pcap", ".pcapng", ".evtx", ".json", ".xml",
|
|
".yaml", ".yml", ".toml",
|
|
".zip", ".tar", ".gz", ".7z",
|
|
".har", ".eml", ".msg",
|
|
})
|
|
|
|
|
|
def validate_upload_permission(
|
|
test: Test,
|
|
team: TeamSide,
|
|
user_role: str,
|
|
) -> None:
|
|
"""Validate that the user can upload evidence for the given team in the current state.
|
|
|
|
Raises:
|
|
PermissionViolation: If user lacks role to upload for this team.
|
|
BusinessRuleViolation: If test state does not allow uploading for this team.
|
|
"""
|
|
if user_role == "admin":
|
|
return
|
|
|
|
if team == TeamSide.red:
|
|
if user_role not in ("red_tech", "red_lead"):
|
|
raise PermissionViolation(
|
|
"Only red_tech, red_lead or admin can upload red evidence"
|
|
)
|
|
if test.state not in RED_EDITABLE_STATES:
|
|
raise BusinessRuleViolation(
|
|
f"Cannot upload red evidence in '{test.state.value}' state "
|
|
"(allowed in: draft, red_executing)"
|
|
)
|
|
elif team == TeamSide.blue:
|
|
if user_role not in ("blue_tech", "blue_lead"):
|
|
raise PermissionViolation(
|
|
"Only blue_tech, blue_lead or admin can upload blue evidence"
|
|
)
|
|
if test.state not in BLUE_EDITABLE_STATES:
|
|
raise BusinessRuleViolation(
|
|
f"Cannot upload blue evidence in '{test.state.value}' state "
|
|
"(allowed in: blue_evaluating)"
|
|
)
|
|
|
|
|
|
def validate_delete_permission(
|
|
test: Test,
|
|
evidence: Evidence,
|
|
user_role: str,
|
|
user_id: uuid.UUID,
|
|
) -> None:
|
|
"""Validate that the user can delete this evidence in the current state.
|
|
|
|
Raises:
|
|
PermissionViolation: If user cannot delete in this state or lacks permission.
|
|
"""
|
|
if test.state in (TestState.in_review, TestState.validated, TestState.rejected):
|
|
raise PermissionViolation(
|
|
f"Cannot delete evidence when test is in '{test.state.value}' state"
|
|
)
|
|
|
|
if user_role == "admin":
|
|
return
|
|
|
|
ev_team = evidence.team
|
|
|
|
if ev_team == TeamSide.red:
|
|
if test.state not in RED_EDITABLE_STATES:
|
|
raise PermissionViolation(
|
|
"Cannot delete red evidence outside draft/red_executing"
|
|
)
|
|
if user_role not in ("red_tech", "red_lead") and evidence.uploaded_by != user_id:
|
|
raise PermissionViolation(
|
|
"Not enough permissions to delete this evidence"
|
|
)
|
|
elif ev_team == TeamSide.blue:
|
|
if test.state not in BLUE_EDITABLE_STATES:
|
|
raise PermissionViolation(
|
|
"Cannot delete blue evidence outside blue_evaluating"
|
|
)
|
|
if user_role not in ("blue_tech", "blue_lead") and evidence.uploaded_by != user_id:
|
|
raise PermissionViolation(
|
|
"Not enough permissions to delete this evidence"
|
|
)
|
|
|
|
|
|
def validate_file(file_name: str, content_size: int) -> None:
|
|
"""Validate file extension and size.
|
|
|
|
Raises:
|
|
BusinessRuleViolation: If extension is not allowed or file exceeds size limit.
|
|
"""
|
|
_, ext = os.path.splitext(file_name)
|
|
ext_lower = ext.lower() if ext else ""
|
|
if ext_lower not in ALLOWED_EXTENSIONS:
|
|
raise BusinessRuleViolation(
|
|
f"File type '{ext}' is not allowed. "
|
|
f"Permitted types: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
|
|
)
|
|
if content_size > MAX_UPLOAD_SIZE:
|
|
raise BusinessRuleViolation(
|
|
f"File exceeds maximum upload size of {MAX_UPLOAD_SIZE // (1024 * 1024)} MB"
|
|
)
|
|
|
|
|
|
def list_evidence_for_test(
|
|
db: Session,
|
|
test_id: uuid.UUID,
|
|
*,
|
|
team: TeamSide | str | None = None,
|
|
) -> list[Evidence]:
|
|
"""Return evidence for a test, optionally filtered by team."""
|
|
query = db.query(Evidence).filter(Evidence.test_id == test_id)
|
|
if team is not None:
|
|
team_enum = TeamSide(team) if isinstance(team, str) else team
|
|
query = query.filter(Evidence.team == team_enum)
|
|
return query.order_by(Evidence.uploaded_at.desc()).all()
|
|
|
|
|
|
def get_evidence_or_raise(db: Session, evidence_id: uuid.UUID) -> Evidence:
|
|
"""Fetch evidence by ID. Raises EntityNotFoundError if not found."""
|
|
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
|
|
if evidence is None:
|
|
raise EntityNotFoundError("Evidence", str(evidence_id))
|
|
return evidence
|
|
|
|
|
|
def get_test_or_raise(db: Session, test_id: uuid.UUID) -> Test:
|
|
"""Fetch test by ID. Raises EntityNotFoundError if not found."""
|
|
test = db.query(Test).filter(Test.id == test_id).first()
|
|
if test is None:
|
|
raise EntityNotFoundError("Test", str(test_id))
|
|
return test
|