feat(tests): archive round history on reopen, stop pausing from setting Jira On Hold
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
- Reopening a test for rework (red or blue) now archives the round that's ending into a new test_round_history table before resetting the live fields — procedure/results/detection data is preserved instead of overwritten, and Phase Timeline renders every past round alongside the current one, so Blue Team keeps visibility into earlier Red attempts. - Each archived round also posts a permanent Jira comment (push_round_archived) since Jira's custom fields only ever show the latest value once the next round resubmits — the comment thread is what preserves full history there. - push_pause_event no longer transitions the Jira issue to "On Hold" — a timer pause is not a real Hold, and flipping Jira status for it was misleading. Only the explicit Hold action (push_hold_event) does that now.
This commit is contained in:
@@ -43,6 +43,7 @@ from app.models.evidence import Evidence
|
||||
from app.models.intel import IntelItem
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.models.user import User
|
||||
|
||||
@@ -86,4 +87,5 @@ __all__ = [
|
||||
"SsoConfig",
|
||||
"AlertRule",
|
||||
"AlertInstance",
|
||||
"TestRoundHistory",
|
||||
]
|
||||
|
||||
@@ -108,6 +108,10 @@ class Test(Base):
|
||||
# Assign blue_paused_seconds = Column(Integer, default=0)
|
||||
blue_paused_seconds = Column(Integer, default=0)
|
||||
|
||||
# ── Round tracking (bumped on each reopen-for-rework) ────────────
|
||||
red_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
||||
blue_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
||||
|
||||
# ── Remediation fields ───────────────────────────────────────────
|
||||
remediation_steps = Column(Text, nullable=True)
|
||||
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
|
||||
@@ -126,6 +130,10 @@ class Test(Base):
|
||||
technique = relationship("Technique", back_populates="tests")
|
||||
# Assign evidences = relationship("Evidence", back_populates="test")
|
||||
evidences = relationship("Evidence", back_populates="test")
|
||||
round_history = relationship(
|
||||
"TestRoundHistory", back_populates="test",
|
||||
order_by="TestRoundHistory.round_number", cascade="all, delete-orphan",
|
||||
)
|
||||
# Assign creator = relationship("User", foreign_keys=[created_by])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""SQLAlchemy model for archived test rounds.
|
||||
|
||||
Each row is a snapshot of one team's round of work (procedure/results for
|
||||
Red, detection/containment for Blue) taken right before a lead reopens the
|
||||
test for rework. Reopening resets the live fields on ``Test`` for a fresh
|
||||
attempt, but the prior attempt's data must not be lost — it's archived here
|
||||
so the full history stays visible (e.g. to Blue Team, who need to see what
|
||||
Red actually did on earlier rounds, not just the latest one).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult
|
||||
|
||||
|
||||
class TestRoundHistory(Base):
|
||||
"""Archived snapshot of one red/blue round of a test, taken on reopen."""
|
||||
|
||||
__tablename__ = "test_round_history"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
|
||||
team = Column(String(10), nullable=False) # "red" or "blue"
|
||||
round_number = Column(Integer, nullable=False)
|
||||
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
ended_at = Column(DateTime, nullable=True)
|
||||
paused_seconds = Column(Integer, default=0)
|
||||
|
||||
# Red-side round output
|
||||
procedure_text = Column(Text, nullable=True)
|
||||
tool_used = Column(String, nullable=True)
|
||||
# native_enum=False — stored as plain VARCHAR, not the shared Postgres
|
||||
# enum type used by tests.attack_success. This is an archive table with
|
||||
# no DB-level constraint needs, and reusing the native type name here
|
||||
# would make Alembic try to (re)create it.
|
||||
attack_success = Column(Enum(AttackSuccessResult, name="test_round_history_attack_success", native_enum=False), nullable=True)
|
||||
red_summary = Column(Text, nullable=True)
|
||||
execution_start_time = Column(DateTime, nullable=True)
|
||||
execution_end_time = Column(DateTime, nullable=True)
|
||||
|
||||
# Blue-side round output
|
||||
detection_result = Column(Enum(TestResult, name="test_round_history_detection_result", native_enum=False), nullable=True)
|
||||
containment_result = Column(Enum(ContainmentResult, name="test_round_history_containment_result", native_enum=False), nullable=True)
|
||||
detection_time = Column(DateTime, nullable=True)
|
||||
containment_time = Column(DateTime, nullable=True)
|
||||
blue_summary = Column(Text, nullable=True)
|
||||
|
||||
# Why the round was closed
|
||||
review_notes = Column(Text, nullable=True)
|
||||
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
archived_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
test = relationship("Test", back_populates="round_history")
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||
@@ -101,6 +101,36 @@ class TestBlueUpdate(BaseModel):
|
||||
blue_summary: str | None = None
|
||||
|
||||
|
||||
# ── Round history (archived on reopen) ─────────────────────────────
|
||||
|
||||
|
||||
class TestRoundHistoryOut(BaseModel):
|
||||
"""One archived round snapshot, taken right before a reopen resets the live fields."""
|
||||
|
||||
id: uuid.UUID
|
||||
team: str
|
||||
round_number: int
|
||||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
paused_seconds: int = 0
|
||||
procedure_text: str | None = None
|
||||
tool_used: str | None = None
|
||||
attack_success: AttackSuccessResult | None = None
|
||||
red_summary: str | None = None
|
||||
execution_start_time: datetime | None = None
|
||||
execution_end_time: datetime | None = None
|
||||
detection_result: TestResult | None = None
|
||||
containment_result: ContainmentResult | None = None
|
||||
detection_time: datetime | None = None
|
||||
containment_time: datetime | None = None
|
||||
blue_summary: str | None = None
|
||||
review_notes: str | None = None
|
||||
reviewed_by: uuid.UUID | None = None
|
||||
archived_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ── Red Lead validation ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -269,6 +299,8 @@ class TestOut(BaseModel):
|
||||
red_paused_seconds: int = 0
|
||||
# Assign blue_paused_seconds = 0
|
||||
blue_paused_seconds: int = 0
|
||||
red_round_number: int = 1
|
||||
blue_round_number: int = 1
|
||||
|
||||
# Remediation fields
|
||||
remediation_steps: str | None = None
|
||||
@@ -313,6 +345,9 @@ class TestOut(BaseModel):
|
||||
red_evidences: list[EvidenceOut] = []
|
||||
blue_evidences: list[EvidenceOut] = []
|
||||
|
||||
# Archived rounds — full history, oldest first (populated from the ORM relationship)
|
||||
round_history: list[TestRoundHistoryOut] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -369,4 +404,11 @@ class TestOut(BaseModel):
|
||||
obj.__dict__["red_evidences"] = red_evs
|
||||
obj.__dict__["blue_evidences"] = blue_evs
|
||||
|
||||
# Only populate round history when already loaded (via joinedload)
|
||||
raw_rounds = obj.__dict__.get("round_history")
|
||||
if raw_rounds is not None:
|
||||
obj.__dict__["round_history"] = [
|
||||
TestRoundHistoryOut.model_validate(r) for r in raw_rounds
|
||||
]
|
||||
|
||||
return obj
|
||||
|
||||
@@ -1025,11 +1025,13 @@ def push_hold_event(
|
||||
|
||||
|
||||
def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None:
|
||||
"""Post a timer pause/resume comment to Jira and toggle 'On Hold' / 'In Progress'.
|
||||
"""Post a timer pause/resume comment to Jira — status is left untouched.
|
||||
|
||||
Distinct from :func:`push_hold_event` — a short timer pause (the
|
||||
operator stepping away briefly) maps to "On Hold", while an explicit
|
||||
hold (something external blocking progress) maps to "Blocked".
|
||||
Distinct from :func:`push_hold_event` — a short timer pause (the timer
|
||||
sitting idle between rework rounds, or the operator briefly stepping
|
||||
away) is not the same thing as the test actually being blocked, so it
|
||||
must not flip the Jira issue to "On Hold". Only a genuine Hold action
|
||||
(:func:`push_hold_event`) does that. This just leaves a record.
|
||||
Non-fatal — any Jira error is logged and swallowed.
|
||||
"""
|
||||
if not has_admin_jira_configured(db):
|
||||
@@ -1052,16 +1054,8 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
|
||||
|
||||
if resuming:
|
||||
comment = f"h3. ▶ Timer Resumed\n\n*Resumed by:* {actor.username}\n*At:* {ts}"
|
||||
try:
|
||||
jira.set_issue_status(link.jira_issue_key, "In Progress")
|
||||
except Exception as exc_t:
|
||||
logger.warning("Could not transition %s off On Hold: %s", link.jira_issue_key, exc_t)
|
||||
else:
|
||||
comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}"
|
||||
try:
|
||||
jira.set_issue_status(link.jira_issue_key, "On Hold")
|
||||
except Exception as exc_t:
|
||||
logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t)
|
||||
|
||||
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||
link.last_synced_at = datetime.utcnow()
|
||||
@@ -1071,6 +1065,58 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
|
||||
logger.warning("Failed to push pause event for test %s: %s", test.id, exc, exc_info=True)
|
||||
|
||||
|
||||
def push_round_archived(db: Session, test, actor, *, round_data) -> None:
|
||||
"""Post a permanent Jira comment summarizing a round right before it's
|
||||
reset for rework.
|
||||
|
||||
``round_data`` custom fields (attack success, detection result, etc.)
|
||||
only ever show Jira's *latest* value — each new round's submission
|
||||
overwrites the field. This comment is what preserves every prior
|
||||
round's results in Jira, since comments are never overwritten.
|
||||
Non-fatal — any Jira error is logged and swallowed.
|
||||
"""
|
||||
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
|
||||
|
||||
try:
|
||||
jira = get_admin_jira_client(db)
|
||||
team = round_data.team
|
||||
lines = [f"h3. \U0001F4CB Round {round_data.round_number} archived ({team} team)\n"]
|
||||
if team == "red":
|
||||
lines.append(f"*Procedure:* {round_data.procedure_text or '_none recorded_'}")
|
||||
lines.append(f"*Tool used:* {round_data.tool_used or '-'}")
|
||||
lines.append(f"*Attack success:* {round_data.attack_success.value if round_data.attack_success else '-'}")
|
||||
lines.append(f"*Summary:* {round_data.red_summary or '-'}")
|
||||
else:
|
||||
lines.append(f"*Detection result:* {round_data.detection_result.value if round_data.detection_result else '-'}")
|
||||
lines.append(f"*Containment result:* {round_data.containment_result.value if round_data.containment_result else '-'}")
|
||||
lines.append(f"*Summary:* {round_data.blue_summary or '-'}")
|
||||
lines.append(f"*Sent back by:* {actor.username}")
|
||||
lines.append(f"*Reopen notes:* {round_data.review_notes or '-'}")
|
||||
comment = "\n".join(lines)
|
||||
|
||||
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||
link.last_synced_at = datetime.utcnow()
|
||||
db.flush()
|
||||
logger.info(
|
||||
"Posted round-archived comment to Jira %s (team=%s, round=%s)",
|
||||
link.jira_issue_key, team, round_data.round_number,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to push round-archived comment for test %s: %s", test.id, exc, exc_info=True)
|
||||
|
||||
|
||||
def push_bt_work_started(db: Session, test, actor) -> None:
|
||||
"""Transition the Jira issue to "In Progress" when a blue tech picks up
|
||||
a queued test to start evaluating.
|
||||
|
||||
@@ -393,7 +393,10 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
|
||||
# Assign test = (
|
||||
test = (
|
||||
db.query(Test)
|
||||
.options(joinedload(Test.evidences), joinedload(Test.technique))
|
||||
.options(
|
||||
joinedload(Test.evidences), joinedload(Test.technique),
|
||||
joinedload(Test.round_history),
|
||||
)
|
||||
.filter(Test.id == test_id)
|
||||
# Chain .first() call
|
||||
.first()
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.models.campaign import Campaign, CampaignTest
|
||||
from app.models.enums import TestState, TeamSide
|
||||
from app.models.evidence import Evidence
|
||||
from app.models.test import Test
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
from app.models.user import User
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import (
|
||||
@@ -383,18 +384,41 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
if not notes or not notes.strip():
|
||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||
|
||||
# Archive this round's data before resetting the live fields — reopening
|
||||
# is for a fresh attempt, not for erasing what Blue Team already saw
|
||||
# about the first one.
|
||||
archived_round = TestRoundHistory(
|
||||
test_id=test.id, team="red", round_number=test.red_round_number or 1,
|
||||
started_at=test.red_started_at, ended_at=datetime.utcnow(),
|
||||
paused_seconds=test.red_paused_seconds or 0,
|
||||
procedure_text=test.procedure_text, tool_used=test.tool_used,
|
||||
attack_success=test.attack_success, red_summary=test.red_summary,
|
||||
execution_start_time=test.execution_start_time, execution_end_time=test.execution_end_time,
|
||||
review_notes=notes.strip(), reviewed_by=user.id,
|
||||
)
|
||||
db.add(archived_round)
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.reopen_red_review()
|
||||
entity.apply_to(test)
|
||||
test.red_review_by = user.id
|
||||
test.red_review_at = datetime.utcnow()
|
||||
test.red_review_notes = notes.strip()
|
||||
test.red_round_number = (test.red_round_number or 1) + 1
|
||||
# New sheet — the operator fills these in fresh for this round; the
|
||||
# values that were just archived stay visible via round_history.
|
||||
test.procedure_text = None
|
||||
test.tool_used = None
|
||||
test.attack_success = None
|
||||
test.red_summary = None
|
||||
test.execution_start_time = None
|
||||
test.execution_end_time = None
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="reopen_red_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||
)
|
||||
|
||||
if test.red_tech_assignee:
|
||||
@@ -409,11 +433,12 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
from app.services.jira_service import push_test_event, push_round_archived
|
||||
original_operator = (
|
||||
db.query(User).filter(User.id == test.red_tech_assignee).first()
|
||||
if test.red_tech_assignee else None
|
||||
)
|
||||
push_round_archived(db, test, user, round_data=archived_round)
|
||||
push_test_event(
|
||||
db, test, user, "red_executing",
|
||||
extra={"notes": notes.strip()}, assignee=original_operator,
|
||||
@@ -594,6 +619,19 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
if not notes or not notes.strip():
|
||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||
|
||||
# Archive this round's data before resetting the live fields — same
|
||||
# reasoning as the red side: don't lose what was already evaluated.
|
||||
archived_round = TestRoundHistory(
|
||||
test_id=test.id, team="blue", round_number=test.blue_round_number or 1,
|
||||
started_at=test.blue_work_started_at, ended_at=datetime.utcnow(),
|
||||
paused_seconds=test.blue_paused_seconds or 0,
|
||||
detection_result=test.detection_result, containment_result=test.containment_result,
|
||||
detection_time=test.detection_time, containment_time=test.containment_time,
|
||||
blue_summary=test.blue_summary,
|
||||
review_notes=notes.strip(), reviewed_by=user.id,
|
||||
)
|
||||
db.add(archived_round)
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.reopen_blue_review()
|
||||
entity.apply_to(test)
|
||||
@@ -601,12 +639,20 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
test.blue_review_by = user.id
|
||||
test.blue_review_at = datetime.utcnow()
|
||||
test.blue_review_notes = notes.strip()
|
||||
test.blue_round_number = (test.blue_round_number or 1) + 1
|
||||
# New sheet for this round; the archived values above remain visible
|
||||
# via round_history.
|
||||
test.detection_result = None
|
||||
test.containment_result = None
|
||||
test.detection_time = None
|
||||
test.containment_time = None
|
||||
test.blue_summary = None
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="reopen_blue_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||
)
|
||||
|
||||
if test.blue_tech_assignee:
|
||||
@@ -621,7 +667,8 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
from app.services.jira_service import push_test_event, push_round_archived
|
||||
push_round_archived(db, test, user, round_data=archived_round)
|
||||
push_test_event(db, test, user, "blue_evaluating", extra={"notes": notes.strip()})
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user