Files
Aegis/backend/app/routers/tests.py
T
kitos 58c0143304
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
feat(permissions): admin no longer acts on the test workflow; managers coordinate operators
Admin administers the site — it no longer has any override on
validate-red/blue, review-red/blue, resolve-dispute, request-discussion,
reopen, hold, resume, assign-operators, or classification-update.
Introduces require_any_role_strict(), a role dependency without the
global admin bypass, so these specific endpoints truly exclude admin
instead of only removing it from the (redundant) role tuple.

Managers gain the ability to assign red_tech/blue_tech operators
(POST /tests/{id}/assign, GET /users/operators) alongside leads, since
that's coordination, not resolving a ticket.

Also enlarges and repositions the operator-assignment controls next
to the Start Execution button, and fixes a literal '\u2014' rendering
as text instead of an em dash in the test detail technique line.
2026-07-10 10:28:04 +02:00

1924 lines
70 KiB
Python

"""CRUD router for security Tests — v2 with Red/Blue workflow.
Endpoints
---------
GET /tests — list with filters (state, technique_id)
POST /tests — create (red_tech, admin)
POST /tests/from-template — create from TestTemplate (red_tech, admin)
GET /tests/{id} — detail with split red/blue evidences
PATCH /tests/{id} — general update (draft/rejected only)
PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
POST /tests/{id}/start-execution — draft → red_executing
POST /tests/{id}/submit-red — red_executing → red_review
POST /tests/{id}/review-red — assigned Red Lead approves/reopens
POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer)
POST /tests/{id}/submit-blue — blue_evaluating → blue_review
POST /tests/{id}/review-blue — assigned Blue Lead approves/reopens/flags gap
POST /tests/{id}/validate-red — Red Lead validates
POST /tests/{id}/validate-blue — Blue Lead validates
POST /tests/{id}/resolve-dispute — approver flips to reject, routes to red/blue queue
POST /tests/{id}/reopen — rejected → draft
GET /tests/{id}/timeline — audit-log history for this test
GET /tests/{id} hides the other team's fields while a test is blind
(draft through blue_review) for red_tech/red_lead/blue_tech/blue_lead
viewers — admin and viewer always see everything.
"""
import base64
import hashlib
import os
import uuid
from datetime import datetime
from typing import Any, Optional
# Import APIRouter, Depends, HTTPException, Query, Reque... from fastapi
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict
# 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
from app.models.enums import AttackSuccessResult, TestState, TestResult, TeamSide
from app.models.evidence import Evidence
from app.storage import upload_file
from app.models.technique import Technique
from app.models.test import Test
from app.models.user import User
# Import from app.schemas.test
from app.schemas.test import (
TestAssign,
TestBlueReview,
TestBlueUpdate,
TestBlueValidate,
TestClassificationUpdate,
TestCreate,
TestHold,
TestOut,
TestRedReview,
TestRedUpdate,
TestRedValidate,
TestRemediationUpdate,
TestResolveDispute,
TestUpdate,
)
# Import TestTemplateInstantiate from app.schemas.test_template
from app.schemas.test_template import TestTemplateInstantiate
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import recalculate_technique_status from app.services.status_service
from app.services.status_service import recalculate_technique_status
from app.services.webhook_service import dispatch_webhook
from app.services.test_crud_service import (
create_test as crud_create_test,
)
from app.services.test_crud_service import determine_initial_classification
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
create_test_from_template as crud_create_from_template,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
get_test_detail as crud_get_test_detail,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
get_test_or_raise as crud_get_test_or_raise,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
get_test_timeline as crud_get_test_timeline,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
get_test_with_technique as crud_get_test_with_technique,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
list_tests as crud_list_tests,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
count_tests as crud_count_tests,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
update_test as crud_update_test,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
update_test_blue as crud_update_test_blue,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
update_test_red as crud_update_test_red,
)
# Import from app.services.test_workflow_service
from app.services.test_workflow_service import (
start_execution as wf_start_execution,
submit_red_evidence as wf_submit_red,
approve_red_review as wf_approve_red_review,
reopen_red_review as wf_reopen_red_review,
submit_blue_evidence as wf_submit_blue,
approve_blue_review as wf_approve_blue_review,
reopen_blue_review as wf_reopen_blue_review,
flag_blue_review_gap as wf_flag_blue_review_gap,
start_blue_work as wf_start_blue_work,
validate_as_red_lead as wf_validate_red,
validate_as_blue_lead as wf_validate_blue,
resolve_dispute as wf_resolve_dispute,
reopen_test as wf_reopen,
handle_remediation_completed as wf_handle_remediation,
get_retest_chain as wf_get_retest_chain,
pause_timer as wf_pause_timer,
resume_timer as wf_resume_timer,
)
# Assign router = APIRouter(prefix="/tests", tags=["tests"])
router = APIRouter(prefix="/tests", tags=["tests"])
# ---------------------------------------------------------------------------
# Blind visibility — hide the other team's fields until both reviews pass
# ---------------------------------------------------------------------------
_RED_ONLY_FIELDS = [
"procedure_text", "tool_used", "attack_success",
"execution_start_time", "execution_end_time", "red_summary",
"red_validation_status", "red_validated_by", "red_validated_at", "red_validation_notes",
]
_BLUE_ONLY_FIELDS = [
"detection_result", "containment_result", "detection_time", "containment_time",
"blue_summary", "blue_validation_status", "blue_validated_by", "blue_validated_at",
"blue_validation_notes", "system_gaps",
]
_BLIND_STATES = {"draft", "red_executing", "red_review", "blue_evaluating", "blue_review"}
def _mask_for_team_blindness(test_out: TestOut, *, viewer_role: str) -> TestOut:
"""Null out the other team's fields while the test is still blind.
admin and viewer are never blinded. Once the test reaches in_review or
beyond, both sides see everything (existing cross-validation behavior).
"""
if viewer_role in ("admin", "viewer"):
return test_out
test_state = test_out.state.value if hasattr(test_out.state, "value") else str(test_out.state)
if test_state not in _BLIND_STATES:
return test_out
if viewer_role in ("blue_tech", "blue_lead"):
hide_fields = _RED_ONLY_FIELDS
elif viewer_role in ("red_tech", "red_lead"):
hide_fields = _BLUE_ONLY_FIELDS
else:
return test_out
return test_out.model_copy(update={f: None for f in hide_fields})
# ---------------------------------------------------------------------------
# GET /tests — list with filters
# ---------------------------------------------------------------------------
def _test_list_filter_params(
# Entry: state
state: Optional[str] = Query(None, description="Filter by test state"),
# Entry: technique_id
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
technique_search: Optional[str] = Query(
None, description="Free-text filter on technique MITRE ID or name"
),
# Entry: platform
platform: Optional[str] = Query(None, description="Filter by platform"),
# Entry: created_by
created_by: Optional[uuid.UUID] = Query(None, description="Filter by creator"),
# Entry: pending_validation_side
pending_validation_side: Optional[str] = Query(
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
),
reviewer_id: Optional[uuid.UUID] = Query(
None,
description="\"My reviews\" queue — filter red_review/blue_review tests assigned to this reviewer",
),
not_in_any_campaign: bool = Query(
False, description="Only return tests not linked to any campaign"
),
attack_success: Optional[str] = Query(None, description="Filter by attack success outcome"),
detection_result: Optional[str] = Query(None, description="Filter by detection outcome"),
validated_from: Optional[datetime] = Query(
None, description="Only tests validated on/after this date"
),
validated_to: Optional[datetime] = Query(
None, description="Only tests validated on/before this date"
),
) -> dict:
"""Shared filter params for GET /tests and GET /tests/count, so the two
endpoints can never silently drift out of sync with each other."""
return {
"state": state,
"technique_id": technique_id,
"technique_search": technique_search,
"platform": platform,
"created_by": created_by,
"pending_validation_side": pending_validation_side,
"reviewer_id": reviewer_id,
"not_in_any_campaign": not_in_any_campaign,
"attack_success": attack_success,
"detection_result": detection_result,
"validated_from": validated_from,
"validated_to": validated_to,
}
@router.get("/count")
def count_tests(
filters: dict = Depends(_test_list_filter_params),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Return the true total of tests matching the given filters.
GET /tests caps `limit` at 200, so pages that show "N results" need
this to report the real total rather than len(page) — which silently
under-reports whenever there are more matches than the page size.
"""
return {"total": crud_count_tests(db, **filters)}
@router.get("", response_model=list[TestOut])
# Define function list_tests
def list_tests(
filters: dict = Depends(_test_list_filter_params),
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list:
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator.
Args:
filters (dict): Shared filter params — see ``_test_list_filter_params``.
offset (int): Number of records to skip for pagination.
limit (int): Maximum number of records to return.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: Serialised list of :class:`TestOut` objects matching the filters.
"""
# Return crud_list_tests(
return crud_list_tests(
db,
**filters,
offset=offset,
# Keyword argument: limit
limit=limit,
)
# ---------------------------------------------------------------------------
# POST /tests — create (red_tech or admin)
# ---------------------------------------------------------------------------
@router.post(
# Literal argument value
"",
# Keyword argument: response_model
response_model=TestOut,
# Keyword argument: status_code
status_code=status.HTTP_201_CREATED,
)
# Apply the @limiter.limit decorator
@limiter.limit("30/minute")
# Define function create_test
def create_test(
# Entry: request
request: Request,
# Entry: payload
payload: TestCreate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> TestOut:
"""Create a new test linked to an existing technique.
``created_by`` is set automatically and ``state`` defaults to *draft*.
Args:
request (Request): FastAPI request object (used by the rate limiter).
payload (TestCreate): Fields for the new test, including ``technique_id``.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead creating the test.
Returns:
TestOut: The newly created test with all fields populated.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = crud_create_test(
test = crud_create_test(
db,
# Keyword argument: technique_id
technique_id=payload.technique_id,
# Keyword argument: creator_id
creator_id=current_user.id,
**payload.model_dump(exclude={"technique_id"}),
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="create_test",
# Keyword argument: entity_type
entity_type="test",
# Keyword argument: entity_id
entity_id=test.id,
# Keyword argument: details
details={"name": test.name, "technique_id": str(test.technique_id)},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Auto-create Jira ticket (non-fatal — any failure is logged, not raised)
try:
from app.services.jira_service import auto_create_test_issue
auto_create_test_issue(db, test, current_user)
db.commit()
except Exception: # nosec B110
pass # jira_service already logs warnings internally
return test
# ---------------------------------------------------------------------------
# POST /tests/from-template — create from TestTemplate
# ---------------------------------------------------------------------------
@router.post(
# Literal argument value
"/from-template",
# Keyword argument: response_model
response_model=TestOut,
# Keyword argument: status_code
status_code=status.HTTP_201_CREATED,
)
# Apply the @limiter.limit decorator
@limiter.limit("30/minute")
# Define function create_test_from_template
def create_test_from_template(
# Entry: request
request: Request,
# Entry: payload
payload: TestTemplateInstantiate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> TestOut:
"""Instantiate a real Test from an existing TestTemplate.
The template's fields are copied into the new test as starting data.
Args:
request (Request): FastAPI request object (used by the rate limiter).
payload (TestTemplateInstantiate): Contains ``template_id`` and target ``technique_id``.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead creating the test.
Returns:
TestOut: The newly created test populated from the template.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = crud_create_from_template(
test = crud_create_from_template(
db,
# Keyword argument: template_id
template_id=payload.template_id,
# Keyword argument: technique_id_or_mitre
technique_id_or_mitre=payload.technique_id,
# Keyword argument: creator_id
creator_id=current_user.id,
name_override=payload.name,
description_override=payload.description,
platform_override=payload.platform,
procedure_text_override=payload.procedure_text,
tool_used_override=payload.tool_used,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="create_test_from_template",
# Keyword argument: entity_type
entity_type="test",
# Keyword argument: entity_id
entity_id=test.id,
# Keyword argument: details
details={
# Literal argument value
"name": test.name,
# Literal argument value
"template_id": str(payload.template_id),
# Literal argument value
"technique_id": str(test.technique_id),
},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Auto-create Jira ticket (non-fatal)
try:
from app.services.jira_service import auto_create_test_issue
auto_create_test_issue(db, test, current_user)
db.commit()
except Exception: # nosec B110
pass # jira_service already logs warnings internally
return test
# ---------------------------------------------------------------------------
# GET /tests/{id} — detail with evidences split by team
# ---------------------------------------------------------------------------
@router.get("/{test_id}", response_model=TestOut)
# Define function get_test
def get_test(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> TestOut:
"""Return full details for a single test, including its evidences.
Args:
test_id (uuid.UUID): Primary key of the test to retrieve.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
TestOut: Full test detail including split red/blue evidence lists.
Fields belonging to the other team are nulled out while the
test is blind (see :func:`_mask_for_team_blindness`).
"""
test = crud_get_test_detail(db, test_id)
test_out = TestOut.model_validate(test)
return _mask_for_team_blindness(test_out, viewer_role=current_user.role)
# ---------------------------------------------------------------------------
# PATCH /tests/{id} — general update (draft / rejected)
# ---------------------------------------------------------------------------
@router.patch("/{test_id}", response_model=TestOut)
# Define function update_test
def update_test(
# Entry: test_id
test_id: uuid.UUID,
# Entry: payload
payload: TestUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> TestOut:
"""Update one or more fields of an existing test.
Only leads or admins can update general test fields.
The test must be in ``draft`` or ``rejected`` state.
Args:
test_id (uuid.UUID): Primary key of the test to update.
payload (TestUpdate): Partial update payload; only set fields are applied.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead performing the update.
Returns:
TestOut: The updated test with refreshed field values.
"""
# Assign update_data = payload.model_dump(exclude_unset=True)
update_data = payload.model_dump(exclude_unset=True)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = crud_update_test(
test = crud_update_test(
db,
test_id,
# Keyword argument: updater_id
updater_id=current_user.id,
# Keyword argument: updater_role
updater_role=current_user.role,
**update_data,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="update_test",
# Keyword argument: entity_type
entity_type="test",
# Keyword argument: entity_id
entity_id=test.id,
# Keyword argument: details
details={"updated_fields": list(update_data.keys())},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# PATCH /tests/{id}/classification — admin data classification
# ---------------------------------------------------------------------------
@router.patch("/{test_id}/classification", response_model=TestOut)
# Define function update_test_classification
def update_test_classification(
# Entry: test_id
test_id: uuid.UUID,
# Entry: payload
payload: TestClassificationUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role_strict(
"manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
)),
) -> TestOut:
"""Update the data classification label for a test.
The initial classification is a best-effort default based on the
technique's tactic — any test participant or admin can correct it.
Args:
test_id (uuid.UUID): Primary key of the test to classify.
payload (TestClassificationUpdate): Contains the new ``data_classification`` value.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated admin user.
Returns:
TestOut: The test with the updated ``data_classification`` field.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Assign test.data_classification = payload.data_classification.value
test.data_classification = payload.data_classification.value
# Flush changes to DB without committing the transaction
db.flush()
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="update_test_classification",
# Keyword argument: entity_type
entity_type="test",
# Keyword argument: entity_id
entity_id=test.id,
# Keyword argument: details
details={"data_classification": payload.data_classification.value},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# PATCH /tests/{id}/red — Red Team update (draft, red_executing)
# ---------------------------------------------------------------------------
@router.patch("/{test_id}/red", response_model=TestOut)
# Define function update_test_red
def update_test_red(
# Entry: test_id
test_id: uuid.UUID,
# Entry: payload
payload: TestRedUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
) -> TestOut:
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).
Args:
test_id (uuid.UUID): Primary key of the test to update.
payload (TestRedUpdate): Red-team-specific fields to update.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_tech or red_lead.
Returns:
TestOut: The updated test with refreshed red-team field values.
"""
# Assignee lock: red_tech cannot work a test assigned to someone else
_pre_test = crud_get_test_or_raise(db, test_id)
if (
_pre_test.red_tech_assignee is not None
and current_user.role == "red_tech"
and _pre_test.red_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
# Assign update_data = payload.model_dump(exclude_unset=True)
update_data = payload.model_dump(exclude_unset=True)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = crud_update_test_red(db, test_id, **update_data)
test = crud_update_test_red(db, test_id, **update_data)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="update_test_red",
# Keyword argument: entity_type
entity_type="test",
# Keyword argument: entity_id
entity_id=test.id,
# Keyword argument: details
details={"updated_fields": list(update_data.keys())},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# PATCH /tests/{id}/blue — Blue Team update (blue_evaluating only)
# ---------------------------------------------------------------------------
@router.patch("/{test_id}/blue", response_model=TestOut)
# Define function update_test_blue
def update_test_blue(
# Entry: test_id
test_id: uuid.UUID,
# Entry: payload
payload: TestBlueUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
) -> TestOut:
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).
Args:
test_id (uuid.UUID): Primary key of the test to update.
payload (TestBlueUpdate): Blue-team-specific fields to update.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated blue_tech or blue_lead.
Returns:
TestOut: The updated test with refreshed blue-team field values.
"""
# Assignee lock: blue_tech cannot work a test assigned to someone else
_pre_test = crud_get_test_or_raise(db, test_id)
if (
_pre_test.blue_tech_assignee is not None
and current_user.role == "blue_tech"
and _pre_test.blue_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
# Assign update_data = payload.model_dump(exclude_unset=True)
update_data = payload.model_dump(exclude_unset=True)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = crud_update_test_blue(db, test_id, **update_data)
test = crud_update_test_blue(db, test_id, **update_data)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="update_test_blue",
# Keyword argument: entity_type
entity_type="test",
# Keyword argument: entity_id
entity_id=test.id,
# Keyword argument: details
details={"updated_fields": list(update_data.keys())},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/start-execution — draft → red_executing
# ---------------------------------------------------------------------------
@router.post("/{test_id}/start-execution", response_model=TestOut)
# Define function start_execution
def start_execution(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
) -> TestOut:
"""Move a test from ``draft`` to ``red_executing``.
Args:
test_id (uuid.UUID): Primary key of the test to start.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_tech or red_lead initiating execution.
Returns:
TestOut: The updated test in ``red_executing`` state.
"""
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Assignee lock: red_tech cannot start a test assigned to someone else
if (
test.red_tech_assignee is not None
and current_user.role == "red_tech"
and test.red_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_start_execution(db, test, current_user)
test = wf_start_execution(db, test, current_user)
# Auto-assign: if no assignee set, claim this test for the current operator
if test.red_tech_assignee is None:
test.red_tech_assignee = current_user.id
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/submit-red — red_executing → blue_evaluating
# ---------------------------------------------------------------------------
@router.post("/{test_id}/submit-red", response_model=TestOut)
# Define function submit_red
def submit_red(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
) -> TestOut:
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.
Args:
test_id (uuid.UUID): Primary key of the test to submit.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_tech or red_lead submitting red evidence.
Returns:
TestOut: The updated test in ``blue_evaluating`` state.
"""
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Assignee lock: red_tech cannot submit a test assigned to someone else
if (
test.red_tech_assignee is not None
and current_user.role == "red_tech"
and test.red_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_submit_red(db, test, current_user)
test = wf_submit_red(db, test, current_user)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/submit-blue — blue_evaluating → in_review
# ---------------------------------------------------------------------------
@router.post("/{test_id}/submit-blue", response_model=TestOut)
# Define function submit_blue
def submit_blue(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
) -> TestOut:
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.
Args:
test_id (uuid.UUID): Primary key of the test to submit.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated blue_tech or blue_lead submitting blue evidence.
Returns:
TestOut: The updated test in ``in_review`` state.
"""
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Assignee lock: blue_tech cannot submit a test assigned to someone else
if (
test.blue_tech_assignee is not None
and current_user.role == "blue_tech"
and test.blue_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_submit_blue(db, test, current_user)
test = wf_submit_blue(db, test, current_user)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/start-blue-work — blue tech picks up test for evaluation
# ---------------------------------------------------------------------------
@router.post("/{test_id}/start-blue-work", response_model=TestOut)
def start_blue_work(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
):
"""Blue tech picks up the test to start evaluating. Sets the Tempo timer start."""
test = crud_get_test_or_raise(db, test_id)
# Assignee lock: blue_tech cannot pick up a test assigned to someone else
if (
test.blue_tech_assignee is not None
and current_user.role == "blue_tech"
and test.blue_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
with UnitOfWork(db) as uow:
test = wf_start_blue_work(db, test, current_user)
# Auto-assign: if no assignee set, claim this test for the current operator
if test.blue_tech_assignee is None:
test.blue_tech_assignee = current_user.id
uow.commit()
db.refresh(test)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/review-red — Red Lead reviews the operator's submission
# ---------------------------------------------------------------------------
@router.post("/{test_id}/review-red", response_model=TestOut)
def review_red(
test_id: uuid.UUID,
payload: TestRedReview,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead")),
) -> TestOut:
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
test = crud_get_test_or_raise(db, test_id)
if test.red_reviewer_assignee != current_user.id:
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
with UnitOfWork(db) as uow:
if payload.decision == "approve":
test = wf_approve_red_review(db, test, current_user, notes=payload.notes)
elif payload.decision == "reopen":
test = wf_reopen_red_review(db, test, current_user, notes=payload.notes or "")
else:
raise HTTPException(status_code=400, detail="decision must be 'approve' or 'reopen'")
uow.commit()
db.refresh(test)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/review-blue — Blue Lead reviews the operator's submission
# ---------------------------------------------------------------------------
@router.post("/{test_id}/review-blue", response_model=TestOut)
def review_blue(
test_id: uuid.UUID,
payload: TestBlueReview,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("blue_lead")),
) -> TestOut:
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review."""
test = crud_get_test_or_raise(db, test_id)
if test.blue_reviewer_assignee != current_user.id:
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
with UnitOfWork(db) as uow:
if payload.decision == "approve":
test = wf_approve_blue_review(db, test, current_user, notes=payload.notes)
elif payload.decision == "reopen":
test = wf_reopen_blue_review(db, test, current_user, notes=payload.notes or "")
elif payload.decision == "gap":
test = wf_flag_blue_review_gap(db, test, current_user, system_gaps=payload.system_gaps or "", notes=payload.notes)
else:
raise HTTPException(status_code=400, detail="decision must be 'approve', 'reopen', or 'gap'")
uow.commit()
db.refresh(test)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/pause-timer — pause the active phase timer
# ---------------------------------------------------------------------------
@router.post("/{test_id}/pause-timer", response_model=TestOut)
# Define function pause_timer
def pause_timer(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
) -> TestOut:
"""Pause the running timer for the current phase (red_executing or blue_evaluating).
Args:
test_id (uuid.UUID): Primary key of the test whose timer should be paused.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated team member in the active phase.
Returns:
TestOut: The updated test with the phase timer paused.
"""
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_pause_timer(db, test, current_user)
test = wf_pause_timer(db, test, current_user)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/resume-timer — resume a paused phase timer
# ---------------------------------------------------------------------------
@router.post("/{test_id}/resume-timer", response_model=TestOut)
# Define function resume_timer
def resume_timer(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
) -> TestOut:
"""Resume the paused timer for the current phase.
Args:
test_id (uuid.UUID): Primary key of the test whose timer should be resumed.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated team member in the active phase.
Returns:
TestOut: The updated test with the phase timer running again.
"""
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_resume_timer(db, test, current_user)
test = wf_resume_timer(db, test, current_user)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/validate-red — Red Lead validates
# ---------------------------------------------------------------------------
@router.post("/{test_id}/validate-red", response_model=TestOut)
# Define function validate_red
def validate_red(
# Entry: test_id
test_id: uuid.UUID,
# Entry: payload
payload: TestRedValidate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role_strict("red_lead")),
) -> TestOut:
"""Red Lead approves or rejects the red side of a test.
Args:
test_id (uuid.UUID): Primary key of the test to validate.
payload (TestRedValidate): Validation status and optional notes from the Red Lead.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead performing the validation.
Returns:
TestOut: The updated test reflecting the red validation decision.
"""
# Assign test = crud_get_test_with_technique(db, test_id)
test = crud_get_test_with_technique(db, test_id)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_validate_red(
test = wf_validate_red(
db, test, current_user,
# Keyword argument: validation_status
validation_status=payload.red_validation_status,
# Keyword argument: notes
notes=payload.red_validation_notes,
)
# Check: test.state in (TestState.validated, TestState.rejected)
if test.state in (TestState.validated, TestState.rejected):
# Call recalculate_technique_status()
recalculate_technique_status(db, test.technique)
# Flag technique for review — coverage changed
if test.technique:
test.technique.review_required = True
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
if test.state == TestState.validated:
dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None})
elif test.state == TestState.rejected:
dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)})
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/validate-blue — Blue Lead validates
# ---------------------------------------------------------------------------
@router.post("/{test_id}/validate-blue", response_model=TestOut)
# Define function validate_blue
def validate_blue(
# Entry: test_id
test_id: uuid.UUID,
# Entry: payload
payload: TestBlueValidate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role_strict("blue_lead")),
) -> TestOut:
"""Blue Lead approves or rejects the blue side of a test.
Args:
test_id (uuid.UUID): Primary key of the test to validate.
payload (TestBlueValidate): Validation status and optional notes from the Blue Lead.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated blue_lead performing the validation.
Returns:
TestOut: The updated test reflecting the blue validation decision.
"""
# Assign test = crud_get_test_with_technique(db, test_id)
test = crud_get_test_with_technique(db, test_id)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_validate_blue(
test = wf_validate_blue(
db, test, current_user,
# Keyword argument: validation_status
validation_status=payload.blue_validation_status,
# Keyword argument: notes
notes=payload.blue_validation_notes,
)
# Check: test.state in (TestState.validated, TestState.rejected)
if test.state in (TestState.validated, TestState.rejected):
# Call recalculate_technique_status()
recalculate_technique_status(db, test.technique)
# Flag technique for review — coverage changed
if test.technique:
test.technique.review_required = True
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
if test.state == TestState.validated:
dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None})
elif test.state == TestState.rejected:
dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)})
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/resolve-dispute — approver flips to reject, picks a queue
# ---------------------------------------------------------------------------
@router.post("/{test_id}/resolve-dispute", response_model=TestOut)
def resolve_dispute(
test_id: uuid.UUID,
payload: TestResolveDispute,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
) -> TestOut:
"""The lead who approved flips their vote to reject, choosing which team must redo the work."""
test = crud_get_test_with_technique(db, test_id)
with UnitOfWork(db) as uow:
test = wf_resolve_dispute(db, test, current_user, payload.target_team, notes=payload.notes)
uow.commit()
db.refresh(test)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/reopen — rejected → draft
# ---------------------------------------------------------------------------
@router.post("/{test_id}/reopen", response_model=TestOut)
# Define function reopen
def reopen(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
) -> TestOut:
"""Reopen a rejected test, moving it back to ``draft``.
Args:
test_id (uuid.UUID): Primary key of the rejected test to reopen.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead reopening the test.
Returns:
TestOut: The updated test in ``draft`` state.
"""
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Open context manager
with UnitOfWork(db) as uow:
# Assign test = wf_reopen(db, test, current_user)
test = wf_reopen(db, test, current_user)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + managers)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/assign", response_model=TestOut)
def assign_test_operators(
test_id: uuid.UUID,
payload: TestAssign,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
):
"""Assign red_tech and/or blue_tech operators to a test. Leads/managers only — not admin, who administers the site rather than coordinating operators."""
test = crud_get_test_or_raise(db, test_id)
newly_assigned: User | None = None
if payload.red_tech_assignee is not None:
u = db.query(User).filter(User.id == payload.red_tech_assignee).first()
if not u or u.role not in ("red_tech", "red_lead", "admin"):
raise HTTPException(status_code=400, detail="Invalid red tech assignee")
test.red_tech_assignee = payload.red_tech_assignee
newly_assigned = u
if payload.blue_tech_assignee is not None:
u = db.query(User).filter(User.id == payload.blue_tech_assignee).first()
if not u or u.role not in ("blue_tech", "blue_lead", "admin"):
raise HTTPException(status_code=400, detail="Invalid blue tech assignee")
test.blue_tech_assignee = payload.blue_tech_assignee
newly_assigned = u
# Handle intentional null (clearing) — model_fields_set tracks which keys were sent
if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None:
test.red_tech_assignee = None
if "blue_tech_assignee" in payload.model_fields_set and payload.blue_tech_assignee is None:
test.blue_tech_assignee = None
log_action(db, current_user.id, "assign_test", str(test_id), {
"red_tech_assignee": str(payload.red_tech_assignee) if payload.red_tech_assignee else None,
"blue_tech_assignee": str(payload.blue_tech_assignee) if payload.blue_tech_assignee else None,
})
db.commit()
db.refresh(test)
if newly_assigned is not None:
try:
from app.services.jira_service import push_assignee_update
push_assignee_update(db, test, newly_assigned)
db.commit()
except Exception: # nosec B110
pass # jira_service already logs warnings internally
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/hold — place a test on hold (red/blue techs and leads)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/hold", response_model=TestOut)
def hold_test(
test_id: uuid.UUID,
payload: TestHold,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
):
"""Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
from datetime import datetime as _dt
from app.services.jira_service import push_hold_event
test = crud_get_test_or_raise(db, test_id)
holdable_states = ("draft", "red_executing", "blue_evaluating")
if test.state not in holdable_states:
raise HTTPException(
status_code=400,
detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.",
)
if test.is_on_hold:
raise HTTPException(status_code=400, detail="Test is already on hold")
test.is_on_hold = True
test.hold_reason = payload.reason
test.held_at = _dt.utcnow()
log_action(db, current_user.id, "hold_test", str(test_id), {"reason": payload.reason})
db.commit()
db.refresh(test)
push_hold_event(db, test, current_user, resuming=False, reason=payload.reason)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/resume — resume a test that was on hold
# ---------------------------------------------------------------------------
@router.post("/{test_id}/resume", response_model=TestOut)
def resume_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
):
"""Resume a test that was placed on hold."""
from app.services.jira_service import push_hold_event
test = crud_get_test_or_raise(db, test_id)
if not test.is_on_hold:
raise HTTPException(status_code=400, detail="Test is not on hold")
test.is_on_hold = False
test.hold_reason = None
test.held_at = None
log_action(db, current_user.id, "resume_test", str(test_id), {})
db.commit()
db.refresh(test)
push_hold_event(db, test, current_user, resuming=True)
return test
# ---------------------------------------------------------------------------
# PATCH /tests/{id}/remediation — update remediation fields
# ---------------------------------------------------------------------------
@router.patch("/{test_id}/remediation", response_model=TestOut)
# Define function update_remediation
def update_remediation(
# Entry: test_id
test_id: uuid.UUID,
# Entry: payload
payload: TestRemediationUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> TestOut:
"""Update remediation fields on a test.
When ``remediation_status`` transitions to ``'completed'``, an automatic
re-test is created (subject to ``MAX_RETEST_COUNT``).
Args:
test_id (uuid.UUID): Primary key of the test to update.
payload (TestRemediationUpdate): Remediation fields to update (status, notes, etc.).
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead updating remediation.
Returns:
TestOut: The updated test with refreshed remediation fields.
"""
# Assign test = crud_get_test_or_raise(db, test_id)
test = crud_get_test_or_raise(db, test_id)
# Assign old_remediation_status = test.remediation_status
old_remediation_status = test.remediation_status
# Assign update_data = payload.model_dump(exclude_unset=True)
update_data = payload.model_dump(exclude_unset=True)
# Iterate over update_data.items()
for field, value in update_data.items():
# Call setattr()
setattr(test, field, value)
# 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="update_remediation",
# Keyword argument: entity_type
entity_type="test",
# Keyword argument: entity_id
entity_id=test.id,
# Keyword argument: details
details={"updated_fields": list(update_data.keys())},
)
# Assign new_status = update_data.get("remediation_status")
new_status = update_data.get("remediation_status")
# Check: new_status == "completed" and old_remediation_status != "completed"
if new_status == "completed" and old_remediation_status != "completed":
# Call wf_handle_remediation()
wf_handle_remediation(db, test, current_user)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(test)
# Return test
return test
# ---------------------------------------------------------------------------
# GET /tests/{id}/timeline — audit history for this test
# ---------------------------------------------------------------------------
@router.get("/{test_id}/timeline")
# Define function get_test_timeline
def get_test_timeline(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list:
"""Return the chronological audit-log history for a test.
Args:
test_id (uuid.UUID): Primary key of the test whose timeline is requested.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: Chronological list of audit-log entries for the test.
"""
# Return crud_get_test_timeline(db, test_id)
return crud_get_test_timeline(db, test_id)
# ---------------------------------------------------------------------------
# GET /tests/{id}/retest-chain — full retest chain
# ---------------------------------------------------------------------------
@router.get("/{test_id}/retest-chain")
# Define function get_retest_chain
def get_retest_chain(
# Entry: test_id
test_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list:
"""Return the full chain of retests (original + all retests) for a test.
Args:
test_id (uuid.UUID): Primary key of any test in the retest chain.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: Ordered list of dicts describing each test in the chain,
including state, result, remediation status, and retest metadata.
"""
# Assign chain = wf_get_retest_chain(db, test_id)
chain = wf_get_retest_chain(db, test_id)
# Check: not chain
if not chain:
# Raise HTTPException
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Test not found")
# Return [
return [
{
# Literal argument value
"id": str(t.id),
# Literal argument value
"name": t.name,
# Literal argument value
"state": t.state.value if t.state else None,
# Literal argument value
"retest_of": str(t.retest_of) if t.retest_of else None,
# Literal argument value
"retest_count": t.retest_count,
# Literal argument value
"result": t.result.value if t.result else None,
# Literal argument value
"detection_result": t.detection_result.value if t.detection_result else None,
# Literal argument value
"remediation_status": t.remediation_status,
# Literal argument value
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in chain
]
# ---------------------------------------------------------------------------
# POST /tests/{id}/sync-tempo — manual Tempo sync for red execution worklog
# ---------------------------------------------------------------------------
@router.post("/{test_id}/sync-tempo")
def sync_tempo(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually sync this test's red team execution worklog(s) to Tempo.
Useful when the automatic sync failed at phase completion (e.g. Tempo
was not yet configured). Only red_team_execution worklogs are eligible.
Already-synced worklogs are skipped. Returns a summary of what happened.
"""
from datetime import datetime as _dt
from app.models.worklog import Worklog
from app.services.tempo_service import auto_log_test_worklog
from app.services.test_crud_service import get_test_or_raise as _get
test = _get(db, test_id)
worklogs = (
db.query(Worklog)
.filter(
Worklog.entity_type == "test",
Worklog.entity_id == test_id,
Worklog.activity_type == "red_team_execution",
)
.all()
)
if not worklogs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No red team execution worklog found for this test.",
)
results = []
for wl in worklogs:
if wl.tempo_synced:
results.append({"worklog_id": str(wl.id), "status": "already_synced"})
continue
try:
result = auto_log_test_worklog(
db=db,
test=test,
user=current_user,
activity_type=wl.activity_type,
duration_seconds=wl.duration_seconds,
)
if result and isinstance(result, dict):
wl.tempo_synced = _dt.utcnow()
wl.tempo_worklog_id = str(result.get("tempoWorklogId", ""))
db.commit()
results.append({"worklog_id": str(wl.id), "status": "synced"})
else:
results.append({
"worklog_id": str(wl.id),
"status": "skipped",
"detail": "Tempo not configured or conditions not met.",
})
except Exception as exc:
results.append({
"worklog_id": str(wl.id),
"status": "error",
"detail": str(exc),
})
return {"results": results}
# ---------------------------------------------------------------------------
# POST /tests/{id}/request-discussion — disputed: confirm vote + notify other lead
# ---------------------------------------------------------------------------
@router.post("/{test_id}/request-discussion")
def request_discussion(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
):
"""Called when the approving lead confirms their vote in a disputed test.
Sends a notification to the other lead (who rejected) asking them to
discuss and resolve the conflict. The test remains in 'disputed' state.
"""
from app.models.user import User as UserModel
from app.services.notification_service import create_notification
test = crud_get_test_or_raise(db, test_id)
if test.state.value != "disputed":
from app.domain.errors import BusinessRuleViolation
raise BusinessRuleViolation("Test is not in disputed state")
role = current_user.role
# Identify who the "other lead" is (the one who rejected)
if role == "red_lead" and test.red_validation_status == "approved":
# Red approved, Blue rejected → notify Blue Lead who rejected
rejector_id = test.blue_validated_by
rejector_label = "Blue Lead"
requester_label = "Red Lead"
elif role == "blue_lead" and test.blue_validation_status == "approved":
# Blue approved, Red rejected → notify Red Lead who rejected
rejector_id = test.red_validated_by
rejector_label = "Red Lead"
requester_label = "Blue Lead"
else:
from app.domain.errors import BusinessRuleViolation
raise BusinessRuleViolation(
"The conflict state is inconsistent — no approving lead found"
)
# Look up the rejecting lead's full info for the response
rejector = (
db.query(UserModel).filter(UserModel.id == rejector_id).first()
if rejector_id else None
)
rejector_name = rejector.username if rejector else rejector_label
rejector_email = getattr(rejector, "email", None) if rejector else None
# Notify the rejecting lead
if rejector_id:
try:
create_notification(
db,
user_id=rejector_id,
type="validation_conflict",
title="Discussion requested on disputed test",
message=(
f"{requester_label} ({current_user.username}) is confirming their approval "
f"of test '{test.name}' and wants to discuss your rejection with you. "
f"Please reach out to resolve the disagreement."
),
entity_type="test",
entity_id=str(test.id),
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(
"Failed to send discussion notification: %s", e
)
log_action(
db,
user_id=current_user.id,
action="request_dispute_discussion",
entity_type="test",
entity_id=test.id,
details={"test_name": test.name, "rejector": rejector_name},
)
db.commit()
return {
"status": "notification_sent",
"message": f"Discussion request sent to {rejector_name}",
"rejector_username": rejector_name,
"rejector_email": rejector_email,
"rejector_role": rejector_label,
}
# ---------------------------------------------------------------------------
# POST /tests/import-rt — bulk import from a real Red Team engagement
# ---------------------------------------------------------------------------
_ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
_MAX_EVIDENCE_BYTES = 10 * 1024 * 1024 # 10 MB decoded per image
class RTEvidenceEntry(BaseModel):
filename: str # e.g. "screenshot_edr.png"
data: str # base64-encoded image content
caption: Optional[str] = None # optional description shown as evidence notes
class RTTechniqueEntry(BaseModel):
mitre_id: str
result: str # "detected" | "not_detected" | "partially_detected"
attack_success: AttackSuccessResult = AttackSuccessResult.successful
platform: Optional[str] = None
notes: Optional[str] = None
evidence: list[RTEvidenceEntry] # REQUIRED — at least one image per technique
class RTImportPayload(BaseModel):
name: str # engagement name, e.g. "Red Team Q1 2024"
date: Optional[str] = None # ISO date string
description: Optional[str] = None
operator: Optional[str] = None # team / company that ran the RT
techniques: list[RTTechniqueEntry]
@router.post("/import-rt", status_code=status.HTTP_201_CREATED)
def import_rt(
payload: RTImportPayload,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead")),
):
"""Import results from a real Red Team engagement.
Creates one Test record per technique in ``validated`` state (bypassing
the normal Red/Blue workflow) and immediately recalculates coverage metrics.
Requires ``red_lead`` or ``admin`` role.
"""
# Pre-validate: every technique must include at least one evidence image
for entry in payload.techniques:
if not entry.evidence:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Technique {entry.mitre_id} is missing evidence. "
"At least one screenshot or image is required per technique."
),
)
# Execution date from payload or now
exec_date_str = payload.date or datetime.utcnow().date().isoformat()
# Result string → TestResult enum
_result_map = {
"detected": TestResult.detected,
"not_detected": TestResult.not_detected,
"partially_detected": TestResult.partially_detected,
}
created: list[dict[str, Any]] = []
skipped: list[dict[str, str]] = []
affected_technique_ids: set = set()
with UnitOfWork(db) as uow:
for entry in payload.techniques:
# Find technique
technique = (
db.query(Technique)
.filter(Technique.mitre_id == entry.mitre_id.upper())
.first()
)
if technique is None:
skipped.append({"mitre_id": entry.mitre_id, "reason": "Technique not found"})
continue
detection_result = _result_map.get(entry.result)
if detection_result is None:
skipped.append({"mitre_id": entry.mitre_id, "reason": f"Unknown result value '{entry.result}'"})
continue
test_name = f"[RT] {payload.name}{technique.name}"
# Build red_summary from notes + engagement metadata
parts = []
if payload.operator:
parts.append(f"Operator: {payload.operator}")
parts.append(f"Engagement date: {exec_date_str}")
if entry.notes:
parts.append(f"\n{entry.notes}")
red_summary_text = "\n".join(parts)
# RT pre-validates the Red side (they ran it), but Blue Lead
# must still validate the detection result before it counts.
# State = in_review so it appears in the Blue Lead's validation queue.
test = Test(
technique_id=technique.id,
name=test_name,
description=payload.description,
platform=entry.platform,
procedure_text=entry.notes,
created_by=current_user.id,
state=TestState.in_review,
# Red team — approved by the RT operator
attack_success=entry.attack_success,
red_summary=red_summary_text,
red_validation_status="approved",
red_validated_by=current_user.id,
red_validated_at=datetime.utcnow(),
# Blue team — pre-fill the detection result but leave
# validation_status pending so Blue Lead must confirm
detection_result=detection_result,
blue_validation_status=None,
data_classification=determine_initial_classification(technique),
# Timing
execution_date=exec_date_str,
created_at=datetime.utcnow(),
)
db.add(test)
db.flush()
# ── Store evidence images ──────────────────────────────
evidence_count = 0
for ev in entry.evidence:
safe_name = os.path.basename(ev.filename) or "evidence.png"
ext = os.path.splitext(safe_name)[1].lower()
if ext not in _ALLOWED_IMAGE_EXTS:
# Skip non-image files silently (log warning)
continue
try:
img_bytes = base64.b64decode(ev.data)
except Exception: # nosec B112
continue # malformed base64 — skip
if len(img_bytes) > _MAX_EVIDENCE_BYTES:
continue # over size limit — skip
sha256 = hashlib.sha256(img_bytes).hexdigest()
key = f"{test.id}/{uuid.uuid4()}_{safe_name}"
try:
upload_file(img_bytes, key)
except Exception: # nosec B112
continue # storage error — skip but don't abort
evidence_obj = Evidence(
test_id=test.id,
file_name=safe_name,
file_path=key,
sha256_hash=sha256,
uploaded_by=current_user.id,
uploaded_at=datetime.utcnow(),
team=TeamSide.red,
notes=ev.caption,
)
db.add(evidence_obj)
evidence_count += 1
affected_technique_ids.add(technique.id)
created.append({
"mitre_id": entry.mitre_id,
"test_name": test_name,
"result": entry.result,
"attack_success": entry.attack_success,
"evidence_attached": evidence_count,
})
log_action(
db,
user_id=current_user.id,
action="rt_import_test",
entity_type="test",
entity_id=test.id,
details={"engagement": payload.name, "mitre_id": entry.mitre_id},
)
# Recalculate coverage for all affected techniques
for tech_id in affected_technique_ids:
tech = db.query(Technique).filter(Technique.id == tech_id).first()
if tech:
recalculate_technique_status(db, tech)
uow.commit()
return {
"created": len(created),
"skipped": len(skipped),
"items": created,
"warnings": skipped,
"engagement": payload.name,
}