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
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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user