Files
mimic/backend/app/api/simulations.py
Knacky b5ea2929de feat(backend): sprint 3 — multi-technique simulations + MITRE matrix
- Simulation model: replace mitre_technique_id/name scalars with techniques JSON column [{id, name}]
- Alembic migration 0003: add techniques, backfill from scalars, drop old columns (reversible)
- MITRE service: add get_tactics(), lookup_name(), get_matrix() with canonical tactic order and sub-technique nesting
- serializer: enrich techniques with tactics from service at serialize time (graceful empty tactics if bundle outdated)
- simulation_workflow: PATCH now accepts technique_ids list, validates against bundle, deduplicates preserving order, auto-transitions on non-empty list
- simulations API: add GET /api/mitre/matrix endpoint (503 if bundle absent)
- test_mitre.py: updated _reset_mitre fixture, added T1059.006 sub-technique, 14 new tests for get_tactics/lookup_name/get_matrix/matrix endpoint
- test_simulations_techniques.py: 20 new tests covering AC-13.1 to AC-13.5 (create, PATCH, dedup, auto-transition, SOC blocked, migration backfill logic)

Total: 161 tests passing. ruff clean. mypy: no new errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:56:02 +02:00

150 lines
4.5 KiB
Python

"""Simulation CRUD + workflow endpoints."""
from __future__ import annotations
from datetime import UTC, datetime
from flask import Blueprint, g, jsonify, request
from backend.app.auth import login_required, role_required
from backend.app.extensions import db
from backend.app.models import Engagement
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.serializers import serialize_simulation
from backend.app.services import simulation_workflow
simulations_bp = Blueprint("simulations", __name__)
# ---------------------------------------------------------------------------
# Nested under /api/engagements/<eid>/simulations
# ---------------------------------------------------------------------------
@simulations_bp.get("/api/engagements/<int:eid>/simulations")
@login_required
def list_simulations(eid: int):
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
sims = (
Simulation.query.filter_by(engagement_id=eid)
.order_by(Simulation.created_at.desc())
.all()
)
return jsonify([serialize_simulation(s) for s in sims]), 200
@simulations_bp.post("/api/engagements/<int:eid>/simulations")
@role_required("admin", "redteam")
def create_simulation(eid: int):
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
sim = Simulation(
engagement_id=eid,
name=name,
status=SimulationStatus.PENDING,
created_at=datetime.now(UTC),
created_by_id=g.current_user.id,
)
db.session.add(sim)
db.session.commit()
return jsonify(serialize_simulation(sim)), 201
# ---------------------------------------------------------------------------
# Flat /api/simulations/<sid>
# ---------------------------------------------------------------------------
@simulations_bp.get("/api/simulations/<int:sid>")
@login_required
def get_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
return jsonify(serialize_simulation(sim)), 200
@simulations_bp.patch("/api/simulations/<int:sid>")
@login_required
def update_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
user = g.current_user
data = request.get_json(silent=True) or {}
if not data:
return jsonify(serialize_simulation(sim)), 200
err = simulation_workflow.apply_patch(sim, data, user)
if err is not None:
return err
db.session.commit()
return jsonify(serialize_simulation(sim)), 200
@simulations_bp.delete("/api/simulations/<int:sid>")
@role_required("admin", "redteam")
def delete_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
db.session.delete(sim)
db.session.commit()
return "", 204
@simulations_bp.post("/api/simulations/<int:sid>/transition")
@login_required
def transition_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
data = request.get_json(silent=True) or {}
to_status = data.get("to", "")
err = simulation_workflow.transition(sim, to_status, g.current_user)
if err is not None:
return err
return jsonify(serialize_simulation(sim)), 200
# ---------------------------------------------------------------------------
# MITRE autocomplete + matrix
# ---------------------------------------------------------------------------
@simulations_bp.get("/api/mitre/techniques")
@login_required
def mitre_techniques():
from backend.app.services import mitre as mitre_svc
if not mitre_svc.mitre_loaded:
return jsonify({"error": "mitre bundle not loaded"}), 503
q = request.args.get("q", "").strip()
results = mitre_svc.search(q)
return jsonify(results), 200
@simulations_bp.get("/api/mitre/matrix")
@login_required
def mitre_matrix():
from backend.app.services import mitre as mitre_svc
if not mitre_svc.mitre_loaded:
return jsonify({"error": "mitre bundle not loaded"}), 503
return jsonify(mitre_svc.get_matrix()), 200