feat(phase-12): implement Red/Blue API endpoints (T-109, T-110, T-111, T-112)

T-109: Rewrite tests router with full Red/Blue workflow endpoints - list with filters, create from template, Red/Blue team updates with state guards, start-execution, submit-red, submit-blue, validate-red, validate-blue, reopen, and timeline. All using workflow service from Phase 11.

T-110: Rewrite evidence router with Red/Blue separation - upload with team field, list with team filter, delete with state-based permissions. Red Team edits in draft/red_executing, Blue Team in blue_evaluating, admin bypasses all.

T-111: Create test_templates router with full CRUD - paginated list with source/platform/severity/search filters, by-technique lookup, admin-only create/update, and soft delete. Registered in main.py.

T-112: Add POST /system/import-atomic-tests endpoint to system router - admin-only trigger for Atomic Red Team import with error handling and statistics response.

Includes validation tests for all four tasks (35 checks total).
This commit is contained in:
2026-02-09 10:45:33 +01:00
parent 7af6be10be
commit 9d7832c571
9 changed files with 1789 additions and 145 deletions

View File

@@ -1,24 +1,110 @@
"""CRUD router for security Tests."""
"""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}/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 Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.enums import TestState
from app.dependencies.auth import get_current_user, require_any_role
from app.models.audit import AuditLog
from app.models.enums import TestState, TeamSide
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.user import User
from app.schemas.test import TestCreate, TestOut, TestUpdate, TestValidate
from app.schemas.test import (
TestCreate,
TestOut,
TestUpdate,
TestRedUpdate,
TestBlueUpdate,
TestRedValidate,
TestBlueValidate,
)
from app.schemas.test_template import TestTemplateInstantiate
from app.services.audit_service import log_action
from app.services.status_service import recalculate_technique_status
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,
validate_as_red_lead as wf_validate_red,
validate_as_blue_lead as wf_validate_blue,
reopen_test as wf_reopen,
)
router = APIRouter(prefix="/tests", tags=["tests"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_test_or_404(db: Session, test_id: uuid.UUID) -> Test:
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Test not found")
return test
def _get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
test = (
db.query(Test)
.options(joinedload(Test.technique))
.filter(Test.id == test_id)
.first()
)
if test is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Test not found")
return test
# ---------------------------------------------------------------------------
# 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"),
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 or technique."""
query = db.query(Test)
if state:
query = query.filter(Test.state == state)
if technique_id:
query = query.filter(Test.technique_id == technique_id)
tests = query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
return tests
# ---------------------------------------------------------------------------
# POST /tests — create (red_tech or admin)
# ---------------------------------------------------------------------------
@@ -36,10 +122,8 @@ def create_test(
):
"""Create a new test linked to an existing technique.
The ``created_by`` field is set automatically to the current user and
``state`` defaults to *draft*.
``created_by`` is set automatically and ``state`` defaults to *draft*.
"""
# Verify the parent technique exists
technique = db.query(Technique).filter(Technique.id == payload.technique_id).first()
if technique is None:
raise HTTPException(
@@ -69,7 +153,70 @@ def create_test(
# ---------------------------------------------------------------------------
# GET /tests/{id} — detail (with evidences)
# POST /tests/from-template — create from TestTemplate
# ---------------------------------------------------------------------------
@router.post(
"/from-template",
response_model=TestOut,
status_code=status.HTTP_201_CREATED,
)
def create_test_from_template(
payload: TestTemplateInstantiate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")),
):
"""Instantiate a real Test from an existing TestTemplate.
The template's fields are copied into the new test as starting data.
"""
template = db.query(TestTemplate).filter(TestTemplate.id == payload.template_id).first()
if template is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"TestTemplate with id '{payload.template_id}' not found",
)
technique = db.query(Technique).filter(Technique.id == payload.technique_id).first()
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique with id '{payload.technique_id}' not found",
)
test = Test(
technique_id=payload.technique_id,
name=template.name,
description=template.description,
platform=template.platform,
procedure_text=template.attack_procedure,
tool_used=template.tool_suggested,
created_by=current_user.id,
state=TestState.draft,
)
db.add(test)
db.commit()
db.refresh(test)
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(template.id),
"technique_id": str(test.technique_id),
},
)
return test
# ---------------------------------------------------------------------------
# GET /tests/{id} — detail with evidences split by team
# ---------------------------------------------------------------------------
@@ -97,7 +244,7 @@ def get_test(
# ---------------------------------------------------------------------------
# PATCH /tests/{id} — update (creator or admin, only in draft/rejected)
# PATCH /tests/{id} — general update (draft / rejected)
# ---------------------------------------------------------------------------
@@ -113,22 +260,14 @@ def update_test(
Only the original creator or an admin can update.
The test must be in ``draft`` or ``rejected`` state.
"""
test = db.query(Test).filter(Test.id == test_id).first()
test = _get_test_or_404(db, test_id)
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
# Ownership / admin check
if current_user.role != "admin" and test.created_by != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# State guard
if test.state not in (TestState.draft, TestState.rejected):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -155,114 +294,29 @@ def update_test(
# ---------------------------------------------------------------------------
# POST /tests/{id}/validate — validate (leads + admin)
# PATCH /tests/{id}/red — Red Team update (draft, red_executing)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/validate", response_model=TestOut)
def validate_test(
@router.patch("/{test_id}/red", response_model=TestOut)
def update_test_red(
test_id: uuid.UUID,
payload: TestValidate,
payload: TestRedUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
current_user: User = Depends(require_any_role("red_tech")),
):
"""Validate the red or blue side of a test (dual validation).
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``)."""
test = _get_test_or_404(db, test_id)
Red Lead approves/rejects the red side; Blue Lead approves/rejects the
blue side. When *both* sides are approved the test state moves to
``validated``. If either side is rejected the state moves to ``rejected``.
"""
test = (
db.query(Test)
.options(joinedload(Test.technique))
.filter(Test.id == test_id)
.first()
)
if test is None:
if test.state not in (TestState.draft, TestState.red_executing):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update red fields in '{test.state.value}' state (must be draft or red_executing)",
)
now = datetime.utcnow()
if current_user.role in ("red_lead", "admin"):
test.red_validation_status = payload.result.value
test.red_validated_by = current_user.id
test.red_validated_at = now
side = "red"
elif current_user.role == "blue_lead":
test.blue_validation_status = payload.result.value
test.blue_validated_by = current_user.id
test.blue_validated_at = now
side = "blue"
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to validate",
)
# Store the overall result from the payload
test.result = payload.result
# Determine aggregate state
red_ok = test.red_validation_status == "approved"
blue_ok = test.blue_validation_status == "approved"
red_rej = test.red_validation_status == "rejected"
blue_rej = test.blue_validation_status == "rejected"
if red_ok and blue_ok:
test.state = TestState.validated
elif red_rej or blue_rej:
test.state = TestState.rejected
else:
test.state = TestState.in_review
db.commit()
db.refresh(test)
# Recalculate the parent technique's global status
technique = test.technique
recalculate_technique_status(db, technique)
log_action(
db,
user_id=current_user.id,
action="validate_test",
entity_type="test",
entity_id=test.id,
details={
"side": side,
"result": payload.result.value,
"technique_id": str(test.technique_id),
},
)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/reject — reject (leads + admin)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/reject", response_model=TestOut)
def reject_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Reject a test, setting its state to *rejected*."""
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
test.state = TestState.rejected
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(test, field, value)
db.commit()
db.refresh(test)
@@ -270,10 +324,215 @@ def reject_test(
log_action(
db,
user_id=current_user.id,
action="reject_test",
action="update_test_red",
entity_type="test",
entity_id=test.id,
details={"technique_id": str(test.technique_id)},
details={"updated_fields": list(update_data.keys())},
)
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 Team updates their fields (allowed only in ``blue_evaluating``)."""
test = _get_test_or_404(db, test_id)
if test.state != TestState.blue_evaluating:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update blue fields in '{test.state.value}' state (must be blue_evaluating)",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(test, field, value)
db.commit()
db.refresh(test)
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())},
)
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")),
):
"""Move a test from ``draft`` to ``red_executing``."""
test = _get_test_or_404(db, test_id)
test = wf_start_execution(db, test, current_user)
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 Team finalises — move from ``red_executing`` to ``blue_evaluating``."""
test = _get_test_or_404(db, test_id)
test = wf_submit_red(db, test, current_user)
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 Team finalises — move from ``blue_evaluating`` to ``in_review``."""
test = _get_test_or_404(db, test_id)
test = wf_submit_blue(db, test, current_user)
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 = _get_test_with_technique(db, test_id)
test = wf_validate_red(
db, test, current_user,
validation_status=payload.red_validation_status,
notes=payload.red_validation_notes,
)
# Recalculate technique status if test reached a terminal state
if test.state in (TestState.validated, TestState.rejected):
recalculate_technique_status(db, test.technique)
db.refresh(test)
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 = _get_test_with_technique(db, test_id)
test = wf_validate_blue(
db, test, current_user,
validation_status=payload.blue_validation_status,
notes=payload.blue_validation_notes,
)
# Recalculate technique status if test reached a terminal state
if test.state in (TestState.validated, TestState.rejected):
recalculate_technique_status(db, test.technique)
db.refresh(test)
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 = _get_test_or_404(db, test_id)
test = wf_reopen(db, test, current_user)
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."""
# Verify the test exists
_get_test_or_404(db, test_id)
logs = (
db.query(AuditLog)
.filter(
AuditLog.entity_type == "test",
AuditLog.entity_id == str(test_id),
)
.order_by(AuditLog.timestamp.asc())
.all()
)
return [
{
"id": str(log.id),
"action": log.action,
"user_id": str(log.user_id) if log.user_id else None,
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
"details": log.details,
}
for log in logs
]