diff --git a/backend/alembic/versions/b050_add_operator_assignees.py b/backend/alembic/versions/b050_add_operator_assignees.py new file mode 100644 index 0000000..862ef46 --- /dev/null +++ b/backend/alembic/versions/b050_add_operator_assignees.py @@ -0,0 +1,24 @@ +"""Add red_tech_assignee and blue_tech_assignee to tests. + +Revision ID: b050 +Revises: b049 +Create Date: 2026-06-19 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "b050" +down_revision = "b049" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("tests", sa.Column("red_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True)) + op.add_column("tests", sa.Column("blue_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tests", "blue_tech_assignee") + op.drop_column("tests", "red_tech_assignee") diff --git a/backend/app/models/test.py b/backend/app/models/test.py index a1ecabe..764f182 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -129,6 +129,14 @@ class Test(Base): blue_validator = relationship("User", foreign_keys=[blue_validated_by]) # Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee]) remediation_user = relationship("User", foreign_keys=[remediation_assignee]) + + # ── Assignment fields ────────────────────────────────────────── + red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee]) + blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee]) + # Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of]) original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of]) # Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig... diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 6b2b369..ee9679d 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -51,6 +51,7 @@ from app.models.user import User # Import from app.schemas.test from app.schemas.test import ( + TestAssign, TestBlueUpdate, TestBlueValidate, TestClassificationUpdate, @@ -563,6 +564,15 @@ def update_test_red( 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 @@ -620,6 +630,15 @@ def update_test_blue( 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 @@ -676,10 +695,22 @@ def start_execution( """ # 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 @@ -715,6 +746,15 @@ def submit_red( """ # 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) @@ -754,6 +794,15 @@ def submit_blue( """ # 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) @@ -779,8 +828,20 @@ def start_blue_work( ): """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 @@ -1017,6 +1078,48 @@ def reopen( return test +# --------------------------------------------------------------------------- +# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + admin) +# --------------------------------------------------------------------------- + + +@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("admin", "red_lead", "blue_lead")), +): + """Assign red_tech and/or blue_tech operators to a test. Admin/leads only.""" + test = crud_get_test_or_raise(db, test_id) + + 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 + + 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 + + # 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) + return test + + # --------------------------------------------------------------------------- # PATCH /tests/{id}/remediation — update remediation fields # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index 2b61b62..f63d78b 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -134,6 +134,13 @@ class TestRemediationUpdate(BaseModel): remediation_assignee: uuid.UUID | None = None +class TestAssign(BaseModel): + """Payload for assigning operators to a test.""" + + red_tech_assignee: uuid.UUID | None = None + blue_tech_assignee: uuid.UUID | None = None + + # ── Legacy validate (kept for backwards compat) ──────────────────── @@ -221,6 +228,10 @@ class TestOut(BaseModel): # Assign remediation_assignee = None remediation_assignee: uuid.UUID | None = None + # Assignment fields + red_tech_assignee: uuid.UUID | None = None + blue_tech_assignee: uuid.UUID | None = None + # Re-test fields retest_of: uuid.UUID | None = None # Assign retest_count = 0 diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 4598e74..1f70217 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -63,6 +63,9 @@ export interface TestListFilters { created_by?: string; pending_validation_side?: "red" | "blue"; not_in_any_campaign?: boolean; + assigned_to_me?: boolean; + unassigned_red?: boolean; + unassigned_blue?: boolean; offset?: number; limit?: number; } @@ -222,6 +225,17 @@ export async function reopenTest(testId: string): Promise { return data; } +// ── Assignment ───────────────────────────────────────────────────── + +/** Assign red_tech and/or blue_tech operators to a test. Admin/leads only. */ +export async function assignTest( + testId: string, + payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null }, +): Promise { + const { data } = await client.post(`/tests/${testId}/assign`, payload); + return data; +} + // ── Timeline ─────────────────────────────────────────────────────── /** Get the audit-log timeline for a test. */ diff --git a/frontend/src/pages/TestsPage.tsx b/frontend/src/pages/TestsPage.tsx index f8a5575..8035a0e 100644 --- a/frontend/src/pages/TestsPage.tsx +++ b/frontend/src/pages/TestsPage.tsx @@ -109,8 +109,22 @@ function formatElapsed(dateStr: string | null | undefined): string { return `${days}d ${hours % 24}h`; } +/* ── Shared sort key type ────────────────────────────────────────────── */ + +type SortKey = + | "name" + | "technique" + | "state" + | "team" + | "platform" + | "created_at" + | "updated_at" + | "waiting_time"; + /* ── Component ──────────────────────────────────────────────────────── */ +const isTechRole = (role?: string) => role === "red_tech" || role === "blue_tech"; + export default function TestsPage() { const navigate = useNavigate(); const { user } = useAuth(); @@ -118,6 +132,8 @@ export default function TestsPage() { const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead"; + const techRole = isTechRole(user?.role); + // ── Filter state ────────────────────────────────────────────────── const [stateFilter, setStateFilter] = useState(""); const [platformFilter, setPlatformFilter] = useState(""); @@ -125,15 +141,6 @@ export default function TestsPage() { const [showMyTasks, setShowMyTasks] = useState(false); // ── Sort state ──────────────────────────────────────────────────── - type SortKey = - | "name" - | "technique" - | "state" - | "team" - | "platform" - | "created_at" - | "updated_at" - | "waiting_time"; const [sortKey, setSortKey] = useState("created_at"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); @@ -290,6 +297,52 @@ export default function TestsPage() { const totalTests = allTestsUnfiltered?.length || 0; + // ── Two-queue split for tech roles ──────────────────────────────── + const { availableTests, myAssignedTests } = useMemo(() => { + if (!techRole || !user || !allTests) { + return { availableTests: [] as typeof tests, myAssignedTests: [] as typeof tests }; + } + + let searchFiltered = allTests.filter((t) => t.state !== "validated"); + if (searchText.trim()) { + const q = searchText.toLowerCase(); + searchFiltered = searchFiltered.filter( + (t) => + t.name.toLowerCase().includes(q) || + (t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) || + (t.technique_name && t.technique_name.toLowerCase().includes(q)) + ); + } + if (platformFilter) { + searchFiltered = searchFiltered.filter((t) => + t.platform?.toLowerCase().includes(platformFilter.toLowerCase()) + ); + } + + if (user.role === "red_tech") { + const available = searchFiltered.filter( + (t) => + (t.state === "draft" || t.state === "red_executing") && + t.red_tech_assignee === null + ); + const mine = searchFiltered.filter((t) => t.red_tech_assignee === user.id); + return { availableTests: available, myAssignedTests: mine }; + } + + if (user.role === "blue_tech") { + const available = searchFiltered.filter( + (t) => + t.state === "blue_evaluating" && + t.blue_tech_assignee === null && + t.blue_work_started_at === null + ); + const mine = searchFiltered.filter((t) => t.blue_tech_assignee === user.id); + return { availableTests: available, myAssignedTests: mine }; + } + + return { availableTests: [] as typeof tests, myAssignedTests: [] as typeof tests }; + }, [techRole, user, allTests, searchText, platformFilter]); + // ── Formatting helpers ───────────────────────────────────────────── const formatDate = (dateStr: string | null | undefined) => { if (!dateStr) return "-"; @@ -425,8 +478,8 @@ export default function TestsPage() { {/* ── Filters Bar ───────────────────────────────────────────────── */}
- {/* My tasks toggle */} - {user?.role !== "admin" && user?.role !== "viewer" && ( + {/* My tasks toggle — hidden for tech roles (they have the two-queue view below) */} + {user?.role !== "admin" && user?.role !== "viewer" && !techRole && (
- {/* ── Tests Table ───────────────────────────────────────────────── */} -
-
-

- {showMyTasks ? myTasksLabel : "All Tests"} -

- {tests.length} tests -
- -
- - - - {mainTableColumns.map(({ key, label, cls }) => ( - - ))} - - - - - {tests.map((test: Test) => ( - navigate(`/tests/${test.id}`)} - > - - - - - - {/* Waiting time — how long since Red submitted to Blue */} - - - - - - ))} - -
handleSort(key)} - > - - {key === "waiting_time" && ( - - )} - {label} - {sortKey === key ? ( - sortDir === "asc" ? ( - - ) : ( - - ) - ) : ( - - )} - - Action
- {test.name} - - {test.technique_mitre_id ? ( -
- - {test.technique_mitre_id} - - - {test.technique_name} - -
- ) : ( - - - )} -
- - {testStateLabels[test.state]} - - - {currentTeamForState(test.state)} - - {test.platform || "-"} - - {test.state === "blue_evaluating" ? ( - - {formatElapsed(test.blue_started_at)} - - ) : ( - - )} - - {formatDate(test.created_at)} - - {formatDate(lastActivityDate(test))} - - -
- - {tests.length === 0 && ( -
- {showMyTasks - ? "No pending tasks for your role." - : "No tests found matching your filters."} + {/* ── Tests Table / Two-Queue View ─────────────────────────────── */} + {techRole ? ( + /* Two-section queue for red_tech and blue_tech */ +
+ {/* Available Tests */} +
+
+
+

Available Tests

+ + {availableTests.length} + +
+ Unassigned — pick one to claim it
- )} + +
+ + {/* My Assigned Tests */} +
+
+
+

My Assigned Tests

+ + {myAssignedTests.length} + +
+ Tests assigned to you +
+ +
-
+ ) : ( + /* Normal single-table view for leads, admin, viewer */ +
+
+

+ {showMyTasks ? myTasksLabel : "All Tests"} +

+ {tests.length} tests +
+ +
+ )} +
+ ); +} + +/* ── Shared test table component ────────────────────────────────────── */ + +function TestTable({ + tests, + columns, + sortKey, + sortDir, + handleSort, + navigate, + formatDate, + emptyMessage, +}: { + tests: Test[]; + columns: { key: SortKey; label: string; cls: string }[]; + sortKey: SortKey; + sortDir: "asc" | "desc"; + handleSort: (key: SortKey) => void; + navigate: (path: string) => void; + formatDate: (d: string | null | undefined) => string; + emptyMessage: string; +}) { + return ( +
+ + + + {columns.map(({ key, label, cls }) => ( + + ))} + + + + + {tests.map((test: Test) => ( + navigate(`/tests/${test.id}`)} + > + + + + + + {/* Waiting time — how long since Red submitted to Blue */} + + + + + + ))} + +
handleSort(key)} + > + + {key === "waiting_time" && ( + + )} + {label} + {sortKey === key ? ( + sortDir === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} + + Action
+ {test.name} + + {test.technique_mitre_id ? ( +
+ + {test.technique_mitre_id} + + + {test.technique_name} + +
+ ) : ( + - + )} +
+ + {testStateLabels[test.state]} + + + {currentTeamForState(test.state)} + + {test.platform || "-"} + + {test.state === "blue_evaluating" ? ( + + {formatElapsed(test.blue_started_at)} + + ) : ( + + )} + + {formatDate(test.created_at)} + + {formatDate(lastActivityDate(test))} + + +
+ + {tests.length === 0 && ( +
{emptyMessage}
+ )}
); } diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index daf2f9f..dabb717 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -110,6 +110,10 @@ export interface Test { remediation_status: string | null; remediation_assignee: string | null; + // Assignment fields + red_tech_assignee: string | null; + blue_tech_assignee: string | null; + // Re-test fields retest_of: string | null; retest_count: number;