fix(jira,tests): send real datetime to RT/BT date fields, resolve assignee usernames for operators
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

- RT/BT Start/End Date are genuine Jira "datetime" custom fields (confirmed
  via issue_editmeta), not plain dates. Pushing a bare "YYYY-MM-DD" string
  made Jira default the time-of-day to midnight, so RT Start Date and RT
  End Date ended up showing the same meaningless midnight timestamp instead
  of the actual execution window. Now sends a full ISO datetime.
- The test detail page showed "RT: Unassigned" for the operator who WAS
  correctly assigned, because GET /users/operators (the only source
  AssigneeControl used to resolve an assignee ID into a username) is
  restricted to leads/managers — a plain operator has no other way to
  resolve their own ID. TestOut now resolves and includes the assignee's
  username directly (red/blue tech + reviewer), so the badge no longer
  depends on a permission-gated list the viewer might not have access to.
This commit is contained in:
kitos
2026-07-13 16:53:31 +02:00
parent 4221d2858b
commit 6d6f87b968
8 changed files with 128 additions and 9 deletions
+25
View File
@@ -330,6 +330,12 @@ class TestOut(BaseModel):
# Assignment fields # Assignment fields
red_tech_assignee: uuid.UUID | None = None red_tech_assignee: uuid.UUID | None = None
blue_tech_assignee: uuid.UUID | None = None blue_tech_assignee: uuid.UUID | None = None
# Resolved usernames — the operator (or lead) viewing a test they're not
# a lead/manager on can't call GET /users/operators (403), so they have
# no other way to resolve who an assignee ID actually is. Populated
# from the ORM relationship, same pattern as technique_name below.
red_tech_assignee_username: str | None = None
blue_tech_assignee_username: str | None = None
# Review assignment fields # Review assignment fields
red_reviewer_assignee: uuid.UUID | None = None red_reviewer_assignee: uuid.UUID | None = None
@@ -340,6 +346,8 @@ class TestOut(BaseModel):
blue_review_by: uuid.UUID | None = None blue_review_by: uuid.UUID | None = None
blue_review_at: datetime | None = None blue_review_at: datetime | None = None
blue_review_notes: str | None = None blue_review_notes: str | None = None
red_reviewer_assignee_username: str | None = None
blue_reviewer_assignee_username: str | None = None
system_gaps: str | None = None system_gaps: str | None = None
# On-hold fields # On-hold fields
@@ -398,6 +406,23 @@ class TestOut(BaseModel):
except Exception: # nosec B110 except Exception: # nosec B110
pass # DetachedInstanceError or similar — leave technique fields None pass # DetachedInstanceError or similar — leave technique fields None
# Resolved assignee usernames (lazy-load, same as technique above).
# A plain operator/tech viewing their own test can't call
# GET /users/operators (lead/manager-only) to resolve an assignee ID
# into a name, so the API needs to hand it over pre-resolved.
for attr, field in (
("red_tech_assigned_user", "red_tech_assignee_username"),
("blue_tech_assigned_user", "blue_tech_assignee_username"),
("red_reviewer", "red_reviewer_assignee_username"),
("blue_reviewer", "blue_reviewer_assignee_username"),
):
try:
user = getattr(obj, attr, None)
if user is not None:
obj.__dict__[field] = user.username
except Exception: # nosec B110
pass # DetachedInstanceError or similar — leave username None
# Only split evidences when they are already in memory (loaded via joinedload) # Only split evidences when they are already in memory (loaded via joinedload)
raw_evs = obj.__dict__.get("evidences") raw_evs = obj.__dict__.get("evidences")
if raw_evs is not None: if raw_evs is not None:
+18 -4
View File
@@ -1202,6 +1202,20 @@ def _select_field(value: str) -> dict:
return {"value": value} return {"value": value}
def _jira_datetime(dt: datetime) -> str:
"""Format a naive-UTC datetime for a Jira ``datetime``-type custom field.
RT/BT Start/End Date are genuine Jira datetime fields (confirmed via
issue_editmeta — schema type "datetime"), not plain dates. Sending a
bare "YYYY-MM-DD" string makes Jira default the time-of-day to
midnight, so RT Start Date and RT End Date ended up showing the same
meaningless midnight timestamp regardless of when the operator
actually started/finished. Jira's REST API expects
``yyyy-MM-dd'T'HH:mm:ss.SSSZ`` for this field type.
"""
return dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]: def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]:
"""Return the elapsed time between two timestamps in hours, or None if unavailable.""" """Return the elapsed time between two timestamps in hours, or None if unavailable."""
if not start or not end: if not start or not end:
@@ -1272,10 +1286,10 @@ def push_rt_submitted(db: Session, test: Test) -> None:
else test.execution_start_time else test.execution_start_time
) )
if first_start: if first_start:
fields[JIRA_FIELD_RT_START_DATE] = first_start.strftime("%Y-%m-%d") fields[JIRA_FIELD_RT_START_DATE] = _jira_datetime(first_start)
if test.execution_end_time: if test.execution_end_time:
fields[JIRA_FIELD_RT_END_DATE] = test.execution_end_time.strftime("%Y-%m-%d") fields[JIRA_FIELD_RT_END_DATE] = _jira_datetime(test.execution_end_time)
attack_success = _enum_value(test.attack_success) attack_success = _enum_value(test.attack_success)
if attack_success in _ATTACK_SUCCESS_TO_JIRA: if attack_success in _ATTACK_SUCCESS_TO_JIRA:
@@ -1287,13 +1301,13 @@ def push_rt_submitted(db: Session, test: Test) -> None:
def push_bt_started(db: Session, test: Test) -> None: def push_bt_started(db: Session, test: Test) -> None:
"""Set the BT Start Date field — called when Blue picks up the test to evaluate.""" """Set the BT Start Date field — called when Blue picks up the test to evaluate."""
_update_test_fields(db, test, { _update_test_fields(db, test, {
JIRA_FIELD_BT_START_DATE: datetime.utcnow().strftime("%Y-%m-%d"), JIRA_FIELD_BT_START_DATE: _jira_datetime(datetime.utcnow()),
}) })
def push_bt_submitted(db: Session, test: Test) -> None: def push_bt_submitted(db: Session, test: Test) -> None:
"""Set BT End Date, Attack Detected/Contained, and time-to-detect/contain — Blue submits for review.""" """Set BT End Date, Attack Detected/Contained, and time-to-detect/contain — Blue submits for review."""
fields: dict = {JIRA_FIELD_BT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")} fields: dict = {JIRA_FIELD_BT_END_DATE: _jira_datetime(datetime.utcnow())}
detection_result = _enum_value(test.detection_result) detection_result = _enum_value(test.detection_result)
if detection_result in _DETECTION_TO_JIRA: if detection_result in _DETECTION_TO_JIRA:
@@ -396,6 +396,8 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
.options( .options(
joinedload(Test.evidences), joinedload(Test.technique), joinedload(Test.evidences), joinedload(Test.technique),
joinedload(Test.round_history), joinedload(Test.round_history),
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
joinedload(Test.red_reviewer), joinedload(Test.blue_reviewer),
) )
.filter(Test.id == test_id) .filter(Test.id == test_id)
# Chain .first() call # Chain .first() call
+4 -4
View File
@@ -397,8 +397,8 @@ def test_push_rt_submitted_uses_execution_times_not_click_time(mock_get_client,
jira_service.push_rt_submitted(db, test) jira_service.push_rt_submitted(db, test)
fields = mock_jira.update_issue_field.call_args[1]["fields"] fields = mock_jira.update_issue_field.call_args[1]["fields"]
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01" assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000"
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01" assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01T11:00:00.000+0000"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@@ -439,8 +439,8 @@ def test_push_rt_submitted_rt_start_date_survives_reopen(mock_get_client, mock_c
jira_service.push_rt_submitted(db, test) jira_service.push_rt_submitted(db, test)
fields = mock_jira.update_issue_field.call_args[1]["fields"] fields = mock_jira.update_issue_field.call_args[1]["fields"]
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01" assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000"
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05" assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05T10:00:00.000+0000"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@@ -0,0 +1,63 @@
"""GET /tests/{id} must resolve assignee IDs into usernames directly, since
GET /users/operators (the only other source of that mapping) is restricted
to leads/managers — a plain operator viewing their own assigned test has no
other way to find out who they're looking at. Regression: the detail page
badge showed "RT: Unassigned" for the assigned operator themselves."""
from app.models.test import Test
from app.models.technique import Technique
def _seed_technique(db) -> Technique:
technique = Technique(
mitre_id="T9996", name="Assignee Username Test Technique",
tactic="execution", platforms=["linux"],
)
db.add(technique)
db.commit()
db.refresh(technique)
return technique
def _seed_test(db, technique, created_by, **overrides) -> Test:
test = Test(technique_id=technique.id, name="Assignee username test", created_by=created_by, **overrides)
db.add(test)
db.commit()
db.refresh(test)
return test
def test_red_tech_assignee_username_resolved_for_the_assigned_operator(
client, db, red_tech_headers, red_tech_user, red_lead_user,
):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id, red_tech_assignee=red_tech_user.id)
resp = client.get(f"/api/v1/tests/{test.id}", headers=red_tech_headers)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["red_tech_assignee"] == str(red_tech_user.id)
assert body["red_tech_assignee_username"] == red_tech_user.username
def test_blue_tech_assignee_username_resolved(client, db, red_lead_headers, red_lead_user, blue_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id, blue_tech_assignee=blue_tech_user.id)
resp = client.get(f"/api/v1/tests/{test.id}", headers=red_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json()["blue_tech_assignee_username"] == blue_tech_user.username
def test_assignee_username_is_none_when_unassigned(client, db, red_lead_headers, red_lead_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.get(f"/api/v1/tests/{test.id}", headers=red_lead_headers)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["red_tech_assignee"] is None
assert body["red_tech_assignee_username"] is None
@@ -15,6 +15,12 @@ interface Props {
* techs+leads. "reviewer" = red_reviewer_assignee/blue_reviewer_assignee, * techs+leads. "reviewer" = red_reviewer_assignee/blue_reviewer_assignee,
* picking from leads only for handing a review off to a peer lead. */ * picking from leads only for handing a review off to a peer lead. */
kind?: "operator" | "reviewer"; kind?: "operator" | "reviewer";
/** Resolved username from the API. `operators` is only fetched for
* leads/managers (GET /users/operators is permission-gated), so a plain
* operator viewing their own test can't resolve assigneeId -> name from
* that list this is the fallback so the badge doesn't misreport
* "Unassigned" just because the picker data isn't available to them. */
assigneeUsername?: string | null;
} }
const SIDE_STYLE = { const SIDE_STYLE = {
@@ -41,10 +47,11 @@ const LABEL_PREFIX: Record<"operator" | "reviewer", Record<"red" | "blue", strin
/** Lead/manager picker for operator assignment or reviewer hand-off. */ /** Lead/manager picker for operator assignment or reviewer hand-off. */
export default function AssigneeControl({ export default function AssigneeControl({
side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm", kind = "operator", side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm", kind = "operator",
assigneeUsername,
}: Props) { }: Props) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const current = operators.find((o) => o.id === assigneeId); const current = operators.find((o) => o.id === assigneeId);
const label = current ? current.username : "Unassigned"; const label = current?.username ?? (assigneeId ? assigneeUsername ?? "Unassigned" : "Unassigned");
const eligible = operators.filter((o) => ELIGIBLE_ROLES[kind][side].includes(o.role)); const eligible = operators.filter((o) => ELIGIBLE_ROLES[kind][side].includes(o.role));
const sideLabel = LABEL_PREFIX[kind][side]; const sideLabel = LABEL_PREFIX[kind][side];
@@ -559,6 +559,7 @@ export default function TestDetailHeader({
side="red" side="red"
kind="reviewer" kind="reviewer"
assigneeId={test.red_reviewer_assignee} assigneeId={test.red_reviewer_assignee}
assigneeUsername={test.red_reviewer_assignee_username}
operators={operators} operators={operators}
canEdit={role === "red_lead" || role === "manager"} canEdit={role === "red_lead" || role === "manager"}
isSaving={isAssigningOperator} isSaving={isAssigningOperator}
@@ -571,6 +572,7 @@ export default function TestDetailHeader({
side="blue" side="blue"
kind="reviewer" kind="reviewer"
assigneeId={test.blue_reviewer_assignee} assigneeId={test.blue_reviewer_assignee}
assigneeUsername={test.blue_reviewer_assignee_username}
operators={operators} operators={operators}
canEdit={role === "blue_lead" || role === "manager"} canEdit={role === "blue_lead" || role === "manager"}
isSaving={isAssigningOperator} isSaving={isAssigningOperator}
@@ -581,6 +583,7 @@ export default function TestDetailHeader({
<AssigneeControl <AssigneeControl
side="red" side="red"
assigneeId={test.red_tech_assignee} assigneeId={test.red_tech_assignee}
assigneeUsername={test.red_tech_assignee_username}
operators={operators} operators={operators}
canEdit={role === "red_lead" || role === "manager"} canEdit={role === "red_lead" || role === "manager"}
isSaving={isAssigningOperator} isSaving={isAssigningOperator}
@@ -590,6 +593,7 @@ export default function TestDetailHeader({
<AssigneeControl <AssigneeControl
side="blue" side="blue"
assigneeId={test.blue_tech_assignee} assigneeId={test.blue_tech_assignee}
assigneeUsername={test.blue_tech_assignee_username}
operators={operators} operators={operators}
canEdit={role === "blue_lead" || role === "manager"} canEdit={role === "blue_lead" || role === "manager"}
isSaving={isAssigningOperator} isSaving={isAssigningOperator}
+4
View File
@@ -134,6 +134,8 @@ export interface Test {
// Assignment fields // Assignment fields
red_tech_assignee: string | null; red_tech_assignee: string | null;
blue_tech_assignee: string | null; blue_tech_assignee: string | null;
red_tech_assignee_username: string | null;
blue_tech_assignee_username: string | null;
// Red Team review fields // Red Team review fields
red_reviewer_assignee: string | null; red_reviewer_assignee: string | null;
@@ -146,6 +148,8 @@ export interface Test {
blue_review_by: string | null; blue_review_by: string | null;
blue_review_at: string | null; blue_review_at: string | null;
blue_review_notes: string | null; blue_review_notes: string | null;
red_reviewer_assignee_username: string | null;
blue_reviewer_assignee_username: string | null;
system_gaps: string | null; system_gaps: string | null;
// On-hold fields // On-hold fields