feat(tests): review-red/review-blue router endpoints
This commit is contained in:
@@ -10,13 +10,19 @@ 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-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 → in_review
|
||||
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}/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
|
||||
@@ -52,12 +58,14 @@ 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,
|
||||
@@ -126,7 +134,12 @@ from app.services.test_crud_service import (
|
||||
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,
|
||||
@@ -141,6 +154,46 @@ from app.services.test_workflow_service import (
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -406,9 +459,12 @@ def get_test(
|
||||
|
||||
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`).
|
||||
"""
|
||||
# Return crud_get_test_detail(db, test_id)
|
||||
return crud_get_test_detail(db, test_id)
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -848,6 +904,68 @@ def start_blue_work(
|
||||
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("red_lead", "admin")),
|
||||
) -> TestOut:
|
||||
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
|
||||
if current_user.role != "admin" and 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("blue_lead", "admin")),
|
||||
) -> 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 current_user.role != "admin" and 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user