feat(backend): sprint 2 — simulations + MITRE ATT&CK
- Simulation model with full field set (redteam + SOC sides) and cascade delete - Alembic migration 0002 for simulations table - simulation_workflow service: PATCH RBAC field-level + auto-transition pending→in_progress + state machine - mitre service: STIX bundle loader (boot-safe) + ranked search (exact-id > prefix-id > name) - 7 new API endpoints: list/create/get/patch/delete simulations, transition, MITRE autocomplete - serialize_simulation added to serializers.py - Makefile update-mitre target with real curl + optional docker restart - Dockerfile updated to copy backend/data/ into image - MITRE enterprise-attack.json bundle committed (~45 MB) - 67 new tests (total 130 passing), ruff clean, mypy introduces no new errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""API blueprints."""
|
||||
from backend.app.api.auth import auth_bp
|
||||
from backend.app.api.engagements import engagements_bp
|
||||
from backend.app.api.simulations import simulations_bp
|
||||
from backend.app.api.users import users_bp
|
||||
|
||||
__all__ = ["auth_bp", "users_bp", "engagements_bp"]
|
||||
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp"]
|
||||
|
||||
141
backend/app/api/simulations.py
Normal file
141
backend/app/api/simulations.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""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
|
||||
if user.role.value not in ("admin", "redteam", "soc"):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user