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])
|
||||
Reference in New Issue
Block a user