feat(assign): let leads manually assign operators + sync Jira; send platform as Jira label
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

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.
This commit is contained in:
kitos
2026-07-10 08:44:14 +02:00
parent 119db6f91d
commit 2afb886cd9
12 changed files with 487 additions and 4 deletions
+12
View File
@@ -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
+29 -2
View File
@@ -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
# ---------------------------------------------------------------------------
+10
View File
@@ -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)
+49 -1
View File
@@ -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
# ---------------------------------------------------------------------------