Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Backend: POST /tests/{id}/request-discussion
- Only callable by the lead whose vote is 'approved' in a disputed test
- Sends notification to the rejecting lead: 'Lead X confirms their
approval and wants to discuss your rejection'
- Logs the action in audit trail
Frontend:
- 'Confirm My Validation' button (amber outline) alongside 'Change to Rejected'
- Opens a modal showing:
* Explanation: both leads must agree to finalise
* Other lead's rejection reason/notes
* What happens next (stays disputed, notification sent, either can change)
- 'Send Discussion Request' → calls the new endpoint → shows success state:
'Lead username has been notified...'
- Instruction to reach out via team channels to resolve offline
Flow summary for disputed tests:
Approving lead sees 2 options:
a) 'Confirm My Validation' → modal → send request → other lead notified
b) 'Change to Rejected' → validation modal → both agree to reject → rejected
975 lines
34 KiB
Python
975 lines
34 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 → blue_evaluating
|
|
POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer)
|
|
POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
|
POST /tests/{id}/validate-red — Red Lead validates
|
|
POST /tests/{id}/validate-blue — Blue Lead validates
|
|
POST /tests/{id}/reopen — rejected → draft
|
|
GET /tests/{id}/timeline — audit-log history for this test
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.dependencies.auth import get_current_user, require_any_role, require_role
|
|
from app.domain.enums import DataClassification
|
|
from app.limiter import limiter
|
|
from app.models.enums import TestState, TestResult
|
|
from app.models.technique import Technique
|
|
from app.models.test import Test
|
|
from app.models.user import User
|
|
from app.schemas.test import (
|
|
TestCreate,
|
|
TestOut,
|
|
TestUpdate,
|
|
TestRedUpdate,
|
|
TestBlueUpdate,
|
|
TestRedValidate,
|
|
TestBlueValidate,
|
|
TestRemediationUpdate,
|
|
TestClassificationUpdate,
|
|
)
|
|
from app.schemas.test_template import TestTemplateInstantiate
|
|
from app.domain.unit_of_work import UnitOfWork
|
|
from app.services.audit_service import log_action
|
|
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,
|
|
create_test_from_template as crud_create_from_template,
|
|
get_test_detail as crud_get_test_detail,
|
|
get_test_or_raise as crud_get_test_or_raise,
|
|
get_test_timeline as crud_get_test_timeline,
|
|
get_test_with_technique as crud_get_test_with_technique,
|
|
list_tests as crud_list_tests,
|
|
update_test as crud_update_test,
|
|
update_test_blue as crud_update_test_blue,
|
|
update_test_red as crud_update_test_red,
|
|
)
|
|
from app.services.test_workflow_service import (
|
|
start_execution as wf_start_execution,
|
|
submit_red_evidence as wf_submit_red,
|
|
submit_blue_evidence as wf_submit_blue,
|
|
start_blue_work as wf_start_blue_work,
|
|
validate_as_red_lead as wf_validate_red,
|
|
validate_as_blue_lead as wf_validate_blue,
|
|
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,
|
|
)
|
|
|
|
router = APIRouter(prefix="/tests", tags=["tests"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests — list with filters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("", response_model=list[TestOut])
|
|
def list_tests(
|
|
state: Optional[str] = Query(None, description="Filter by test state"),
|
|
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
|
|
platform: Optional[str] = Query(None, description="Filter by platform"),
|
|
created_by: Optional[uuid.UUID] = Query(None, description="Filter by creator"),
|
|
pending_validation_side: Optional[str] = Query(
|
|
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
|
|
),
|
|
not_in_any_campaign: bool = Query(
|
|
False, description="Only return tests not linked to any campaign"
|
|
),
|
|
offset: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator."""
|
|
return crud_list_tests(
|
|
db,
|
|
state=state,
|
|
technique_id=technique_id,
|
|
platform=platform,
|
|
created_by=created_by,
|
|
pending_validation_side=pending_validation_side,
|
|
not_in_any_campaign=not_in_any_campaign,
|
|
offset=offset,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests — create (red_tech or admin)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=TestOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
@limiter.limit("30/minute")
|
|
def create_test(
|
|
request: Request,
|
|
payload: TestCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
):
|
|
"""Create a new test linked to an existing technique.
|
|
|
|
``created_by`` is set automatically and ``state`` defaults to *draft*.
|
|
"""
|
|
with UnitOfWork(db) as uow:
|
|
test = crud_create_test(
|
|
db,
|
|
technique_id=payload.technique_id,
|
|
creator_id=current_user.id,
|
|
**payload.model_dump(exclude={"technique_id"}),
|
|
)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="create_test",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={"name": test.name, "technique_id": str(test.technique_id)},
|
|
)
|
|
uow.commit()
|
|
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:
|
|
pass # jira_service already logs warnings internally
|
|
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/from-template — create from TestTemplate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post(
|
|
"/from-template",
|
|
response_model=TestOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
@limiter.limit("30/minute")
|
|
def create_test_from_template(
|
|
request: Request,
|
|
payload: TestTemplateInstantiate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
):
|
|
"""Instantiate a real Test from an existing TestTemplate.
|
|
|
|
The template's fields are copied into the new test as starting data.
|
|
"""
|
|
with UnitOfWork(db) as uow:
|
|
test = crud_create_from_template(
|
|
db,
|
|
template_id=payload.template_id,
|
|
technique_id_or_mitre=payload.technique_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,
|
|
)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="create_test_from_template",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={
|
|
"name": test.name,
|
|
"template_id": str(payload.template_id),
|
|
"technique_id": str(test.technique_id),
|
|
},
|
|
)
|
|
uow.commit()
|
|
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:
|
|
pass
|
|
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests/{id} — detail with evidences split by team
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{test_id}", response_model=TestOut)
|
|
def get_test(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return full details for a single test, including its evidences."""
|
|
return crud_get_test_detail(db, test_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id} — general update (draft / rejected)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}", response_model=TestOut)
|
|
def update_test(
|
|
test_id: uuid.UUID,
|
|
payload: TestUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
):
|
|
"""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.
|
|
"""
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
with UnitOfWork(db) as uow:
|
|
test = crud_update_test(
|
|
db,
|
|
test_id,
|
|
updater_id=current_user.id,
|
|
updater_role=current_user.role,
|
|
**update_data,
|
|
)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="update_test",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/classification — admin data classification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/classification", response_model=TestOut)
|
|
def update_test_classification(
|
|
test_id: uuid.UUID,
|
|
payload: TestClassificationUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin")),
|
|
):
|
|
"""Update the data classification label for a test (admin only)."""
|
|
with UnitOfWork(db) as uow:
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
test.data_classification = payload.data_classification.value
|
|
db.flush()
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="update_test_classification",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={"data_classification": payload.data_classification.value},
|
|
)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/red — Red Team update (draft, red_executing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/red", response_model=TestOut)
|
|
def update_test_red(
|
|
test_id: uuid.UUID,
|
|
payload: TestRedUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
|
):
|
|
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``)."""
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
with UnitOfWork(db) as uow:
|
|
test = crud_update_test_red(db, test_id, **update_data)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="update_test_red",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/blue — Blue Team update (blue_evaluating only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/blue", response_model=TestOut)
|
|
def update_test_blue(
|
|
test_id: uuid.UUID,
|
|
payload: TestBlueUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
|
):
|
|
"""Blue Team updates their fields (allowed only in ``blue_evaluating``)."""
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
with UnitOfWork(db) as uow:
|
|
test = crud_update_test_blue(db, test_id, **update_data)
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="update_test_blue",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/start-execution — draft → red_executing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/start-execution", response_model=TestOut)
|
|
def start_execution(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
|
):
|
|
"""Move a test from ``draft`` to ``red_executing``."""
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_start_execution(db, test, current_user)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/submit-red", response_model=TestOut)
|
|
def submit_red(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
|
):
|
|
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``."""
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_submit_red(db, test, current_user)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/submit-blue", response_model=TestOut)
|
|
def submit_blue(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
|
):
|
|
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``."""
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_submit_blue(db, test, current_user)
|
|
uow.commit()
|
|
db.refresh(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)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_start_blue_work(db, test, current_user)
|
|
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)
|
|
def pause_timer(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
|
):
|
|
"""Pause the running timer for the current phase (red_executing or blue_evaluating)."""
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_pause_timer(db, test, current_user)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/resume-timer — resume a paused phase timer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/resume-timer", response_model=TestOut)
|
|
def resume_timer(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
|
):
|
|
"""Resume the paused timer for the current phase."""
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_resume_timer(db, test, current_user)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/{id}/validate-red — Red Lead validates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/validate-red", response_model=TestOut)
|
|
def validate_red(
|
|
test_id: uuid.UUID,
|
|
payload: TestRedValidate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_lead")),
|
|
):
|
|
"""Red Lead approves or rejects the red side of a test."""
|
|
test = crud_get_test_with_technique(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_validate_red(
|
|
db, test, current_user,
|
|
validation_status=payload.red_validation_status,
|
|
notes=payload.red_validation_notes,
|
|
)
|
|
if test.state in (TestState.validated, TestState.rejected):
|
|
recalculate_technique_status(db, test.technique)
|
|
# Flag technique for review — coverage changed
|
|
if test.technique:
|
|
test.technique.review_required = True
|
|
uow.commit()
|
|
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)
|
|
def validate_blue(
|
|
test_id: uuid.UUID,
|
|
payload: TestBlueValidate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("blue_lead")),
|
|
):
|
|
"""Blue Lead approves or rejects the blue side of a test."""
|
|
test = crud_get_test_with_technique(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_validate_blue(
|
|
db, test, current_user,
|
|
validation_status=payload.blue_validation_status,
|
|
notes=payload.blue_validation_notes,
|
|
)
|
|
if test.state in (TestState.validated, TestState.rejected):
|
|
recalculate_technique_status(db, test.technique)
|
|
# Flag technique for review — coverage changed
|
|
if test.technique:
|
|
test.technique.review_required = True
|
|
uow.commit()
|
|
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}/reopen — rejected → draft
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/{test_id}/reopen", response_model=TestOut)
|
|
def reopen(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
):
|
|
"""Reopen a rejected test, moving it back to ``draft``."""
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
with UnitOfWork(db) as uow:
|
|
test = wf_reopen(db, test, current_user)
|
|
uow.commit()
|
|
db.refresh(test)
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tests/{id}/remediation — update remediation fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.patch("/{test_id}/remediation", response_model=TestOut)
|
|
def update_remediation(
|
|
test_id: uuid.UUID,
|
|
payload: TestRemediationUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
):
|
|
"""Update remediation fields on a test.
|
|
|
|
When ``remediation_status`` transitions to ``'completed'``, an automatic
|
|
re-test is created (subject to ``MAX_RETEST_COUNT``).
|
|
"""
|
|
test = crud_get_test_or_raise(db, test_id)
|
|
|
|
old_remediation_status = test.remediation_status
|
|
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(test, field, value)
|
|
|
|
with UnitOfWork(db) as uow:
|
|
log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="update_remediation",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={"updated_fields": list(update_data.keys())},
|
|
)
|
|
|
|
new_status = update_data.get("remediation_status")
|
|
if new_status == "completed" and old_remediation_status != "completed":
|
|
wf_handle_remediation(db, test, current_user)
|
|
|
|
uow.commit()
|
|
|
|
db.refresh(test)
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests/{id}/timeline — audit history for this test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{test_id}/timeline")
|
|
def get_test_timeline(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return the chronological audit-log history for a test."""
|
|
return crud_get_test_timeline(db, test_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tests/{id}/retest-chain — full retest chain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{test_id}/retest-chain")
|
|
def get_retest_chain(
|
|
test_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return the full chain of retests (original + all retests) for a test."""
|
|
chain = wf_get_retest_chain(db, test_id)
|
|
if not chain:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Test not found")
|
|
|
|
return [
|
|
{
|
|
"id": str(t.id),
|
|
"name": t.name,
|
|
"state": t.state.value if t.state else None,
|
|
"retest_of": str(t.retest_of) if t.retest_of else None,
|
|
"retest_count": t.retest_count,
|
|
"result": t.result.value if t.result else None,
|
|
"detection_result": t.detection_result.value if t.detection_result else None,
|
|
"remediation_status": t.remediation_status,
|
|
"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("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.enums import TestState as ModelTestState
|
|
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(
|
|
"You are not the approving lead in this conflict or the state is inconsistent"
|
|
)
|
|
|
|
# Look up the rejecting lead's username for the message
|
|
rejector = (
|
|
db.query(UserModel).filter(UserModel.id == rejector_id).first()
|
|
if rejector_id else None
|
|
)
|
|
rejector_name = rejector.username if rejector else rejector_label
|
|
|
|
# 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,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tests/import-rt — bulk import from a real Red Team engagement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class RTTechniqueEntry(BaseModel):
|
|
mitre_id: str
|
|
result: str # "detected" | "not_detected" | "partially_detected"
|
|
attack_success: bool = True
|
|
platform: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
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.
|
|
"""
|
|
# 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,
|
|
# Timing
|
|
execution_date=exec_date_str,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
db.add(test)
|
|
db.flush()
|
|
|
|
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,
|
|
})
|
|
|
|
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,
|
|
}
|
|
|