From 2afb886cd9f75f4a517ce48c27a94d7db71ca37d Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 10 Jul 2026 08:44:14 +0200 Subject: [PATCH] feat(assign): let leads manually assign operators + sync Jira; send platform as Jira label Adds GET /users/operators (leads+admin) and wires a lead-only assign control into the test detail header, calling the existing but previously unreachable POST /tests/{id}/assign endpoint. Manual assignment now also pushes the Jira ticket assignee immediately instead of waiting for the operator to start execution. Also adds test.platform as a kebab-case label on Jira ticket creation. --- backend/app/routers/tests.py | 12 ++ backend/app/routers/users.py | 31 ++++- backend/app/schemas/user.py | 10 ++ backend/app/services/jira_service.py | 50 +++++++- backend/tests/test_assign_operators.py | 111 ++++++++++++++++++ backend/tests/test_jira_service.py | 84 ++++++++++++- backend/tests/test_list_operators.py | 33 ++++++ frontend/src/api/tests.ts | 9 ++ frontend/src/api/users.ts | 12 ++ .../test-detail/AssigneeControl.tsx | 90 ++++++++++++++ .../test-detail/TestDetailHeader.tsx | 26 ++++ frontend/src/pages/TestDetailPage.tsx | 23 ++++ 12 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_assign_operators.py create mode 100644 backend/tests/test_list_operators.py create mode 100644 frontend/src/components/test-detail/AssigneeControl.tsx diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 9a76697..189d776 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -1282,18 +1282,21 @@ def assign_test_operators( ): """Assign red_tech and/or blue_tech operators to a test. Admin/leads only.""" 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: @@ -1307,6 +1310,15 @@ def assign_test_operators( }) 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 diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index ea441a5..4bfe0db 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session from app.database import get_db # Import require_role from app.dependencies.auth -from app.dependencies.auth import require_role +from app.dependencies.auth import require_role, require_any_role # Import UnitOfWork from app.domain.unit_of_work from app.domain.unit_of_work import UnitOfWork @@ -21,7 +21,7 @@ from app.domain.unit_of_work import UnitOfWork # Import User from app.models.user from app.models.user import User from app.dependencies.auth import get_current_user -from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate +from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate, UserOperatorOut from app.services.audit_service import log_action # Import from app.services.user_service @@ -148,6 +148,33 @@ def create_user_route( return user +# --------------------------------------------------------------------------- +# GET /users/operators — minimal operator/lead list for assignment pickers +# --------------------------------------------------------------------------- + + +@router.get("/operators", response_model=list[UserOperatorOut]) +def list_operators_route( + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")), +) -> list[UserOperatorOut]: + """Return active red/blue operators and leads, for lead/admin assignment pickers. + + Deliberately excludes viewers and managers, and returns only + id/username/role — no emails or tokens — since this is reachable by + non-admin leads. + """ + return ( + db.query(User) + .filter( + User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead", "admin"]), + User.is_active.is_(True), + ) + .order_by(User.username) + .all() + ) + + # --------------------------------------------------------------------------- # GET /users/{id} — get a single user # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 337515f..b85f8ff 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -284,3 +284,13 @@ class UserOut(BaseModel): self.jira_token_set = bool(self.jira_api_token) self.tempo_token_set = bool(self.tempo_api_token) return self + + +class UserOperatorOut(BaseModel): + """Minimal user representation for lead/admin operator-picker dropdowns.""" + + id: uuid.UUID + username: str + role: str + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index b7f3642..43b8ca2 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -31,6 +31,9 @@ from __future__ import annotations # Import logging import logging +# Import re +import re + # Import datetime from datetime from datetime import datetime @@ -697,12 +700,16 @@ def auto_create_test_issue( poc = test.procedure_text or "N/A" severity = _technique_severity(technique).capitalize() + labels = ["aegis", "security-test", mitre_id.replace(".", "-")] + if test.platform: + # Jira labels can't contain whitespace — normalize to kebab-case. + labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-")) fields: dict = { "project": {"key": project_key}, "summary": f"[Aegis] {mitre_id} — {test.name}", "description": _build_test_description(test, technique), "issuetype": {"name": issue_type}, - "labels": ["aegis", "security-test", mitre_id.replace(".", "-")], + "labels": labels, # customfield_10309 = Proof of Concept field (required by team's Jira config) "customfield_10309": f"{{code}}{poc}{{code}}", JIRA_FIELD_TTP: mitre_id, @@ -862,6 +869,47 @@ def push_test_event( ) +def push_assignee_update(db: Session, test: Test, assignee: User) -> None: + """Sync a lead's manual operator assignment to the linked Jira ticket. + + Called from ``POST /tests/{id}/assign`` so Jira reflects the assignment + immediately, instead of waiting for the operator to start execution + (the only other point that pushes a Jira assignee — see + ``push_test_event``'s ``red_executing`` handling above). + """ + if not has_admin_jira_configured(db): + return + + link = ( + db.query(JiraLink) + .filter( + JiraLink.entity_type == JiraLinkEntityType.test, + JiraLink.entity_id == test.id, + ) + .first() + ) + if not link: + return + + jira_account_id = getattr(assignee, "jira_account_id", None) + if not jira_account_id: + return + + try: + jira = get_admin_jira_client(db) + jira.assign_issue(link.jira_issue_key, account_id=jira_account_id) + link.last_synced_at = datetime.utcnow() + db.flush() + logger.info( + "Assigned Jira ticket %s to account %s (manual assignment)", + link.jira_issue_key, jira_account_id, + ) + except Exception as exc: + logger.warning( + "Could not assign %s to %s: %s", link.jira_issue_key, jira_account_id, exc, + ) + + # --------------------------------------------------------------------------- # On-hold Jira notification # --------------------------------------------------------------------------- diff --git a/backend/tests/test_assign_operators.py b/backend/tests/test_assign_operators.py new file mode 100644 index 0000000..d8d3266 --- /dev/null +++ b/backend/tests/test_assign_operators.py @@ -0,0 +1,111 @@ +"""Tests for POST /tests/{id}/assign — lead/admin manual operator assignment.""" + +from unittest.mock import MagicMock, patch + +from app.models.test import Test +from app.models.technique import Technique + + +def _seed_technique(db, tactic="execution") -> Technique: + technique = Technique( + mitre_id="T9999", + name="Test Technique", + tactic=tactic, + platforms=["linux"], + ) + db.add(technique) + db.commit() + db.refresh(technique) + return technique + + +def _seed_test(db, technique, created_by) -> Test: + test = Test(technique_id=technique.id, name="Assignable test", created_by=created_by) + db.add(test) + db.commit() + db.refresh(test) + return test + + +def test_red_lead_can_assign_red_tech(client, db, red_lead_headers, red_lead_user, red_tech_user): + technique = _seed_technique(db) + test = _seed_test(db, technique, red_lead_user.id) + + resp = client.post( + f"/api/v1/tests/{test.id}/assign", + json={"red_tech_assignee": str(red_tech_user.id)}, + headers=red_lead_headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["red_tech_assignee"] == str(red_tech_user.id) + + db.refresh(test) + assert test.red_tech_assignee == red_tech_user.id + + +def test_assign_rejects_wrong_role_for_side(client, db, red_lead_headers, red_lead_user, blue_tech_user): + technique = _seed_technique(db) + test = _seed_test(db, technique, red_lead_user.id) + + resp = client.post( + f"/api/v1/tests/{test.id}/assign", + json={"red_tech_assignee": str(blue_tech_user.id)}, + headers=red_lead_headers, + ) + assert resp.status_code == 400 + + +def test_red_tech_cannot_assign(client, db, red_tech_headers, red_tech_user): + technique = _seed_technique(db) + test = _seed_test(db, technique, red_tech_user.id) + + resp = client.post( + f"/api/v1/tests/{test.id}/assign", + json={"red_tech_assignee": str(red_tech_user.id)}, + headers=red_tech_headers, + ) + assert resp.status_code == 403 + + +def test_clearing_assignee_does_not_touch_jira(client, db, red_lead_headers, red_lead_user, red_tech_user): + """Sending null explicitly clears the assignee without a Jira sync call.""" + technique = _seed_technique(db) + test = _seed_test(db, technique, red_lead_user.id) + test.red_tech_assignee = red_tech_user.id + db.commit() + + with patch("app.services.jira_service.push_assignee_update") as mock_push: + resp = client.post( + f"/api/v1/tests/{test.id}/assign", + json={"red_tech_assignee": None}, + headers=red_lead_headers, + ) + assert resp.status_code == 200 + assert resp.json()["red_tech_assignee"] is None + mock_push.assert_not_called() + + +def test_assigning_operator_syncs_to_jira(client, db, red_lead_headers, red_lead_user, red_tech_user): + technique = _seed_technique(db) + test = _seed_test(db, technique, red_lead_user.id) + + # Capture IDs *during* the call, while the endpoint's request-scoped + # session is still open — reading them afterward hits a + # DetachedInstanceError once that session closes. + captured = {} + + def _capture(_db, test_arg, assignee_arg): + captured["test_id"] = test_arg.id + captured["assignee_id"] = assignee_arg.id + + with patch("app.services.jira_service.push_assignee_update", side_effect=_capture) as mock_push: + resp = client.post( + f"/api/v1/tests/{test.id}/assign", + json={"red_tech_assignee": str(red_tech_user.id)}, + headers=red_lead_headers, + ) + + assert resp.status_code == 200 + mock_push.assert_called_once() + assert captured["test_id"] == test.id + assert captured["assignee_id"] == red_tech_user.id diff --git a/backend/tests/test_jira_service.py b/backend/tests/test_jira_service.py index 8476533..fa6cbd2 100644 --- a/backend/tests/test_jira_service.py +++ b/backend/tests/test_jira_service.py @@ -383,6 +383,44 @@ def test_auto_create_test_issue_maps_data_classification_to_real_jira_field( assert fields["customfield_11814"] == {"value": expected_jira_value} +@pytest.mark.parametrize( + "platform,expected_label", + [ + ("Windows", "windows"), + ("linux", "linux"), + ("macOS", "macos"), + ("Azure AD", "azure-ad"), + (None, None), + ], +) +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_jira_project_key", return_value="SEC") +@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None) +@patch("app.services.jira_service.get_admin_jira_client") +def test_auto_create_test_issue_adds_platform_label( + mock_get_client, mock_parent, mock_project_key, mock_configured, db, + platform, expected_label, +): + mock_jira = MagicMock() + mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"} + mock_get_client.return_value = mock_jira + from app.models.user import User + test = _make_test_for_issue(platform=platform) + technique = _make_technique() + actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x") + db.add(actor) + db.commit() + + jira_service.auto_create_test_issue(db, test, actor, technique=technique) + + labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"] + assert ["aegis", "security-test", "T1003-006"] == labels[:3] + if expected_label: + assert expected_label in labels + else: + assert len(labels) == 3 + + def _make_user(**overrides): from app.models.user import User u = MagicMock(spec=User) @@ -428,4 +466,48 @@ def test_lookup_user_jira_account_id_no_match(mock_get_client, mock_configured, updated = jira_service.lookup_user_jira_account_id(db, user) assert updated is False - assert user.jira_account_id is None + + +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_admin_jira_client") +def test_push_assignee_update_assigns_jira_issue(mock_get_client, mock_configured, db): + mock_jira = MagicMock() + mock_get_client.return_value = mock_jira + test = _make_test() + link = _make_link(test.id) + db.add(link) + db.commit() + assignee = _make_user(jira_account_id="account-42") + + jira_service.push_assignee_update(db, test, assignee) + + mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="account-42") + + +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_admin_jira_client") +def test_push_assignee_update_noop_without_jira_account_id(mock_get_client, mock_configured, db): + mock_jira = MagicMock() + mock_get_client.return_value = mock_jira + test = _make_test() + link = _make_link(test.id) + db.add(link) + db.commit() + assignee = _make_user(jira_account_id=None) + + jira_service.push_assignee_update(db, test, assignee) + + mock_jira.assign_issue.assert_not_called() + + +@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) +@patch("app.services.jira_service.get_admin_jira_client") +def test_push_assignee_update_noop_without_link(mock_get_client, mock_configured, db): + mock_jira = MagicMock() + mock_get_client.return_value = mock_jira + test = _make_test() + assignee = _make_user(jira_account_id="account-42") + + jira_service.push_assignee_update(db, test, assignee) + + mock_jira.assign_issue.assert_not_called() diff --git a/backend/tests/test_list_operators.py b/backend/tests/test_list_operators.py new file mode 100644 index 0000000..1ecc446 --- /dev/null +++ b/backend/tests/test_list_operators.py @@ -0,0 +1,33 @@ +"""Tests for GET /users/operators — lead/admin-reachable operator picker list.""" + + +def test_red_lead_can_list_operators(client, red_lead_headers, red_tech_user, blue_tech_user, red_lead_user): + resp = client.get("/api/v1/users/operators", headers=red_lead_headers) + assert resp.status_code == 200, resp.text + usernames = {u["username"] for u in resp.json()} + assert "redtech" in usernames + assert "bluetech" in usernames + assert "redlead" in usernames + + +def test_operators_list_excludes_viewers(client, red_lead_headers, db): + from app.auth import hash_password + from app.models.user import User + + viewer = User( + username="viewer_ops", email="viewer_ops@test.com", + hashed_password=hash_password("x"), role="viewer", is_active=True, + must_change_password=False, + ) + db.add(viewer) + db.commit() + + resp = client.get("/api/v1/users/operators", headers=red_lead_headers) + assert resp.status_code == 200 + usernames = {u["username"] for u in resp.json()} + assert "viewer_ops" not in usernames + + +def test_red_tech_cannot_list_operators(client, red_tech_headers): + resp = client.get("/api/v1/users/operators", headers=red_tech_headers) + assert resp.status_code == 403 diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 29784e3..25f6a95 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -179,6 +179,15 @@ export async function updateTestClassification( return data; } +/** Assign red_tech/blue_tech operators to a test (leads + admin only). */ +export async function assignTestOperators( + 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; +} + // ── Red Team ─────────────────────────────────────────────────────── /** Red Team updates their fields (draft, red_executing). */ diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index de051a1..238cc14 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -30,6 +30,18 @@ export async function getUsers(): Promise { return data; } +export interface OperatorOut { + id: string; + username: string; + role: string; +} + +/** Fetch red/blue operators + leads for assignment pickers (leads + admin). */ +export async function getOperators(): Promise { + const { data } = await client.get("/users/operators"); + return data; +} + /** Create a new user (admin only). */ export async function createUser(payload: UserCreatePayload): Promise { const { data } = await client.post("/users", payload); diff --git a/frontend/src/components/test-detail/AssigneeControl.tsx b/frontend/src/components/test-detail/AssigneeControl.tsx new file mode 100644 index 0000000..c88e52a --- /dev/null +++ b/frontend/src/components/test-detail/AssigneeControl.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { ChevronDown, ChevronUp, Loader2, UserCircle2 } from "lucide-react"; +import type { OperatorOut } from "../../api/users"; + +interface Props { + side: "red" | "blue"; + assigneeId: string | null; + operators: OperatorOut[]; + canEdit: boolean; + isSaving: boolean; + onAssign: (userId: string | null) => void; +} + +const SIDE_STYLE = { + red: "border-orange-500/30 bg-orange-900/20 text-orange-400", + blue: "border-indigo-500/30 bg-indigo-900/20 text-indigo-400", +}; + +const SIDE_ROLES: Record<"red" | "blue", string[]> = { + red: ["red_tech", "red_lead", "admin"], + blue: ["blue_tech", "blue_lead", "admin"], +}; + +/** Lead/admin picker for the red_tech_assignee / blue_tech_assignee fields. */ +export default function AssigneeControl({ side, assigneeId, operators, canEdit, isSaving, onAssign }: Props) { + const [expanded, setExpanded] = useState(false); + const current = operators.find((o) => o.id === assigneeId); + const label = current ? current.username : "Unassigned"; + const eligible = operators.filter((o) => SIDE_ROLES[side].includes(o.role)); + + const badgeClass = `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`; + + if (!canEdit) { + return ( + + + {side === "red" ? "RT" : "BT"}: {label} + + ); + } + + return ( +
+ + + {expanded && ( +
+ {isSaving ? ( +
+ +
+ ) : ( + <> + + {eligible.map((op) => ( + + ))} + {eligible.length === 0 && ( +

No eligible operators

+ )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index 2f04fe9..b604828 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -19,8 +19,10 @@ import { import { useMutation } from "@tanstack/react-query"; import { requestDiscussion } from "../../api/tests"; import type { Test, TestState, User, DataClassification } from "../../types/models"; +import type { OperatorOut } from "../../api/users"; import LiveTimer from "./LiveTimer"; import DataClassificationBadge from "./DataClassificationBadge"; +import AssigneeControl from "./AssigneeControl"; // ── Progress steps ───────────────────────────────────────────────── @@ -90,6 +92,9 @@ interface TestDetailHeaderProps { isTogglingHold: boolean; onUpdateClassification: (value: DataClassification) => void; isUpdatingClassification: boolean; + operators: OperatorOut[]; + onAssignOperator: (side: "red" | "blue", userId: string | null) => void; + isAssigningOperator: boolean; } // ── Component ────────────────────────────────────────────────────── @@ -114,6 +119,9 @@ export default function TestDetailHeader({ isTogglingHold, onUpdateClassification, isUpdatingClassification, + operators, + onAssignOperator, + isAssigningOperator, }: TestDetailHeaderProps) { const role = user?.role ?? ""; const currentIdx = STATE_INDEX[test.state]; @@ -539,6 +547,24 @@ export default function TestDetailHeader({

Created {formatDate(test.created_at)}

+
+ onAssignOperator("red", userId)} + /> + onAssignOperator("blue", userId)} + /> +
{renderValidationIndicators()} diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index c852792..2883f13 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -23,9 +23,11 @@ import { holdTest, resumeTest, updateTestClassification, + assignTestOperators, getTestTimeline, getRetestChain, } from "../api/tests"; +import { getOperators } from "../api/users"; import { uploadEvidence, getEvidence } from "../api/evidence"; import { useAuth } from "../context/AuthContext"; import type { TestResult, ContainmentResult, AttackSuccessResult, DataClassification, TeamSide, TestTimelineEntry } from "../types/models"; @@ -133,6 +135,14 @@ export default function TestDetailPage() { enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0), }); + const canAssignOperators = ["red_lead", "blue_lead", "admin"].includes(user?.role ?? ""); + const { data: operators = [] } = useQuery({ + queryKey: ["operators"], + queryFn: getOperators, + enabled: canAssignOperators, + staleTime: 5 * 60 * 1000, + }); + // Hydrate drafts from test data useEffect(() => { if (test) { @@ -368,6 +378,16 @@ export default function TestDetailPage() { onError: (err: unknown) => showToast(extractError(err), "error"), }); + const assignOperatorMutation = useMutation({ + mutationFn: ({ side, userId }: { side: "red" | "blue"; userId: string | null }) => + assignTestOperators(testId!, side === "red" ? { red_tech_assignee: userId } : { blue_tech_assignee: userId }), + onSuccess: () => { + invalidateAll(); + showToast("Assignment updated", "success"); + }, + onError: (err: unknown) => showToast(extractError(err), "error"), + }); + // Evidence upload const uploadMutation = useMutation({ mutationFn: ({ file, team }: { file: File; team: TeamSide }) => @@ -532,6 +552,9 @@ export default function TestDetailPage() { isTogglingHold={holdMutation.isPending || resumeHoldMutation.isPending} onUpdateClassification={(value) => updateClassificationMutation.mutate(value)} isUpdatingClassification={updateClassificationMutation.isPending} + operators={operators} + onAssignOperator={(side, userId) => assignOperatorMutation.mutate({ side, userId })} + isAssigningOperator={assignOperatorMutation.isPending} /> {/* Content: Tabs + Sidebar */}