feat(backend): sprint 4 — tactic_ids + done guard + engagement auto-status
- Simulation model: add tactic_ids JSON column (nullable=False, default=[])
- Migration 0004: ADD COLUMN tactic_ids (server_default='[]', no batch needed)
- mitre.py: add _TACTIC_IDS map, lookup_tactic(), get_tactic_name()
- simulation_workflow.py: done guard (409) before RBAC; SOC gate += tactic_ids;
_resolve_tactic_ids() validates against hardcoded map; auto-transition += tactic_ids;
transition done→review_required is Reopen (all 3 roles); _maybe_activate_engagement hook
- serializers.py: _enrich_tactics() → serialize_simulation adds tactics:[{id,name}]
- test_simulations_tactics.py: valid/invalid/dedup/SOC gate/auto-transition/no-bundle
- test_simulations_done_readonly.py: 409 all roles, Reopen all roles, invalid transitions, after-reopen ok
- test_engagement_lifecycle.py: planned→active on auto-transition, already active/closed unchanged, migration 0004 round-trip
- Updated test_simulations_patch.py + test_simulations_workflow.py for AC-18 behavior
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ class Simulation(db.Model): # type: ignore[name-defined]
|
||||
)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
techniques = db.Column(db.JSON, nullable=False, default=list)
|
||||
tactic_ids = db.Column(db.JSON, nullable=False, default=list)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
commands = db.Column(db.Text, nullable=True)
|
||||
prerequisites = db.Column(db.Text, nullable=True)
|
||||
|
||||
@@ -30,12 +30,27 @@ def _enrich_techniques(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _enrich_tactics(tactic_ids: list[str]) -> list[dict[str, str]]:
|
||||
"""Resolve TA-ids to {id, name} at runtime."""
|
||||
from backend.app.services import mitre as mitre_svc
|
||||
|
||||
result = []
|
||||
for tid in tactic_ids or []:
|
||||
entry = mitre_svc.lookup_tactic(tid)
|
||||
if entry is not None:
|
||||
result.append(entry)
|
||||
else:
|
||||
result.append({"id": tid, "name": ""})
|
||||
return result
|
||||
|
||||
|
||||
def serialize_simulation(simulation: Simulation) -> dict[str, Any]:
|
||||
return {
|
||||
"id": simulation.id,
|
||||
"engagement_id": simulation.engagement_id,
|
||||
"name": simulation.name,
|
||||
"techniques": _enrich_techniques(simulation.techniques or []),
|
||||
"tactics": _enrich_tactics(simulation.tactic_ids or []),
|
||||
"description": simulation.description,
|
||||
"commands": simulation.commands,
|
||||
"prerequisites": simulation.prerequisites,
|
||||
|
||||
@@ -26,6 +26,22 @@ _TACTIC_ORDER = [
|
||||
"impact",
|
||||
]
|
||||
|
||||
# TA-id → short-name mapping (MITRE Enterprise, IDs are not sequential).
|
||||
_TACTIC_IDS: dict[str, str] = {
|
||||
"TA0001": "initial-access",
|
||||
"TA0002": "execution",
|
||||
"TA0003": "persistence",
|
||||
"TA0004": "privilege-escalation",
|
||||
"TA0005": "defense-evasion",
|
||||
"TA0006": "credential-access",
|
||||
"TA0007": "discovery",
|
||||
"TA0008": "lateral-movement",
|
||||
"TA0009": "collection",
|
||||
"TA0011": "command-and-control",
|
||||
"TA0010": "exfiltration",
|
||||
"TA0040": "impact",
|
||||
}
|
||||
|
||||
TACTIC_NAMES: dict[str, str] = {
|
||||
"initial-access": "Initial Access",
|
||||
"execution": "Execution",
|
||||
@@ -181,6 +197,22 @@ def get_matrix() -> list[dict[str, Any]]:
|
||||
return _matrix
|
||||
|
||||
|
||||
def lookup_tactic(tactic_id: str) -> dict[str, str] | None:
|
||||
"""Return {id, name} for a TA-id, or None if unknown."""
|
||||
short = _TACTIC_IDS.get(tactic_id)
|
||||
if short is None:
|
||||
return None
|
||||
return {"id": tactic_id, "name": TACTIC_NAMES[short]}
|
||||
|
||||
|
||||
def get_tactic_name(tactic_id: str) -> str | None:
|
||||
"""Return the display name for a TA-id, or None if unknown."""
|
||||
short = _TACTIC_IDS.get(tactic_id)
|
||||
if short is None:
|
||||
return None
|
||||
return TACTIC_NAMES[short]
|
||||
|
||||
|
||||
def search(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Return up to `limit` techniques matching `query`.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from backend.app.extensions import db
|
||||
from backend.app.models import User
|
||||
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||
|
||||
# Fields only admin/redteam may write (excluding technique_ids which is handled separately).
|
||||
# Fields only admin/redteam may write (excluding technique_ids/tactic_ids handled separately).
|
||||
REDTEAM_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
@@ -58,7 +58,6 @@ def _resolve_technique_ids(
|
||||
if not mitre_svc.mitre_loaded:
|
||||
return None, (jsonify({"error": "mitre bundle not loaded"}), 503)
|
||||
|
||||
# Dedup, preserve order.
|
||||
seen: dict[str, None] = dict.fromkeys(technique_ids)
|
||||
resolved: list[dict[str, str]] = []
|
||||
for tid in seen:
|
||||
@@ -69,6 +68,36 @@ def _resolve_technique_ids(
|
||||
return resolved, None
|
||||
|
||||
|
||||
def _resolve_tactic_ids(
|
||||
tactic_ids: list[str],
|
||||
) -> tuple[list[str] | None, tuple[Any, int] | None]:
|
||||
"""Validate and deduplicate tactic TA-ids.
|
||||
|
||||
Returns (deduped_list, None) on success or (None, error_tuple) on failure.
|
||||
Bundle does not need to be loaded — validation is against the hardcoded _TACTIC_IDS map.
|
||||
"""
|
||||
from backend.app.services import mitre as mitre_svc
|
||||
|
||||
seen: dict[str, None] = dict.fromkeys(tactic_ids)
|
||||
for tid in seen:
|
||||
if mitre_svc.lookup_tactic(tid) is None:
|
||||
return None, (jsonify({"error": f"unknown tactic id: {tid}"}), 400)
|
||||
return list(seen), None
|
||||
|
||||
|
||||
def _maybe_activate_engagement(simulation: Simulation) -> None:
|
||||
"""If simulation's engagement is planned, advance it to active.
|
||||
|
||||
Caller must commit — do not commit here to avoid double-commit.
|
||||
"""
|
||||
from backend.app.models.engagement import Engagement, EngagementStatus
|
||||
|
||||
engagement: Engagement | None = getattr(simulation, "engagement", None)
|
||||
if engagement is not None and engagement.status == EngagementStatus.PLANNED:
|
||||
engagement.status = EngagementStatus.ACTIVE
|
||||
db.session.add(engagement)
|
||||
|
||||
|
||||
def apply_patch(
|
||||
simulation: Simulation, payload: dict[str, Any], user: User
|
||||
) -> tuple[Any, int] | None:
|
||||
@@ -77,6 +106,10 @@ def apply_patch(
|
||||
Returns a (response, status_code) tuple on error, or None on success
|
||||
(caller is responsible for committing).
|
||||
"""
|
||||
# Done guard — applies to ALL roles before any RBAC check.
|
||||
if simulation.status == SimulationStatus.DONE:
|
||||
return jsonify({"error": "simulation is done — reopen first"}), 409
|
||||
|
||||
role = user.role.value
|
||||
|
||||
if role == "soc":
|
||||
@@ -86,8 +119,10 @@ def apply_patch(
|
||||
):
|
||||
return jsonify({"error": "simulation not ready for SOC review"}), 403
|
||||
|
||||
# SOC must not send redteam fields or technique_ids.
|
||||
redteam_keys_in_payload = (REDTEAM_FIELDS | {"technique_ids"}) & payload.keys()
|
||||
# SOC must not send redteam fields, technique_ids, or tactic_ids.
|
||||
redteam_keys_in_payload = (
|
||||
REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}
|
||||
) & payload.keys()
|
||||
if redteam_keys_in_payload:
|
||||
return jsonify({"error": "soc cannot edit redteam fields"}), 403
|
||||
|
||||
@@ -121,6 +156,16 @@ def apply_patch(
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
# Validate and deduplicate tactic_ids upfront.
|
||||
resolved_tactic_ids: list[str] | None = None
|
||||
if "tactic_ids" in payload:
|
||||
raw_tids = payload["tactic_ids"]
|
||||
if not isinstance(raw_tids, list):
|
||||
return jsonify({"error": "tactic_ids must be a list"}), 400
|
||||
resolved_tactic_ids, err = _resolve_tactic_ids(raw_tids)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
# Apply scalar redteam fields.
|
||||
for field in redteam_keys_present:
|
||||
if field == "executed_at":
|
||||
@@ -132,19 +177,26 @@ def apply_patch(
|
||||
if resolved_techniques is not None:
|
||||
simulation.techniques = resolved_techniques
|
||||
|
||||
# Apply resolved tactic_ids.
|
||||
if resolved_tactic_ids is not None:
|
||||
simulation.tactic_ids = resolved_tactic_ids
|
||||
|
||||
# Apply SOC fields (admin/redteam may also write them).
|
||||
for field in SOC_FIELDS:
|
||||
if field in payload:
|
||||
setattr(simulation, field, payload[field])
|
||||
|
||||
# Auto-transition pending → in_progress.
|
||||
# Triggers when any redteam scalar has a non-empty value, OR technique_ids is non-empty.
|
||||
# Triggers when any redteam scalar has a non-empty value, technique_ids or tactic_ids non-empty.
|
||||
auto_trigger = any(_is_non_empty(payload[k]) for k in redteam_keys_present)
|
||||
if not auto_trigger and "technique_ids" in payload:
|
||||
auto_trigger = len(payload["technique_ids"]) > 0
|
||||
if not auto_trigger and "tactic_ids" in payload:
|
||||
auto_trigger = len(payload["tactic_ids"]) > 0
|
||||
|
||||
if simulation.status == SimulationStatus.PENDING and auto_trigger:
|
||||
simulation.status = SimulationStatus.IN_PROGRESS
|
||||
_maybe_activate_engagement(simulation)
|
||||
|
||||
simulation.updated_at = datetime.now(UTC)
|
||||
return None
|
||||
@@ -154,6 +206,13 @@ def transition(
|
||||
simulation: Simulation, to_status: str, user: User
|
||||
) -> tuple[Any, int] | None:
|
||||
"""Attempt a manual transition. Returns error tuple or None on success."""
|
||||
# Special case: done → review_required (Reopen), allowed for all 3 roles.
|
||||
if to_status == "review_required" and simulation.status == SimulationStatus.DONE:
|
||||
simulation.status = SimulationStatus.REVIEW_REQUIRED
|
||||
simulation.updated_at = datetime.now(UTC)
|
||||
db.session.commit()
|
||||
return None
|
||||
|
||||
rule = _ALLOWED_TRANSITIONS.get(to_status)
|
||||
if rule is None:
|
||||
return jsonify({"error": "invalid transition"}), 409
|
||||
@@ -166,5 +225,10 @@ def transition(
|
||||
|
||||
simulation.status = SimulationStatus(to_status)
|
||||
simulation.updated_at = datetime.now(UTC)
|
||||
|
||||
# Hook: auto-activate engagement when simulation enters in_progress via manual transition.
|
||||
if simulation.status == SimulationStatus.IN_PROGRESS:
|
||||
_maybe_activate_engagement(simulation)
|
||||
|
||||
db.session.commit()
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user