feat(backend): sprint 5 — SimulationTemplate CRUD + instantiation
- SimulationTemplate model + migration 0005 (CREATE TABLE + name index) - 5 CRUD endpoints under /api/templates (admin|redteam only, SOC 403) - POST /api/engagements/<eid>/simulations extended with optional template_id - serialize_template() reusing _enrich_techniques/_enrich_tactics helpers - IntegrityError → 409 for duplicate name on both POST and PATCH - 28 new tests (CRUD, RBAC, dedup, instantiation, migration round-trip) - 221 tests pass; ruff clean; mypy clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
|
||||
from backend.app.api import auth_bp, engagements_bp, simulations_bp, users_bp
|
||||
from backend.app.api import auth_bp, engagements_bp, simulations_bp, templates_bp, users_bp
|
||||
from backend.app.cli import register_cli
|
||||
from backend.app.config import Config, TestConfig
|
||||
from backend.app.errors import register_error_handlers
|
||||
@@ -37,6 +37,7 @@ def create_app(config_object: object | None = None) -> Flask:
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(engagements_bp)
|
||||
app.register_blueprint(simulations_bp)
|
||||
app.register_blueprint(templates_bp)
|
||||
|
||||
from backend.app.services import mitre as mitre_svc
|
||||
mitre_svc.load_bundle()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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.templates import templates_bp
|
||||
from backend.app.api.users import users_bp
|
||||
|
||||
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp"]
|
||||
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp", "templates_bp"]
|
||||
|
||||
@@ -46,6 +46,7 @@ def create_simulation(eid: int):
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
|
||||
template_id = data.get("template_id")
|
||||
sim = Simulation(
|
||||
engagement_id=eid,
|
||||
name=name,
|
||||
@@ -53,6 +54,19 @@ def create_simulation(eid: int):
|
||||
created_at=datetime.now(UTC),
|
||||
created_by_id=g.current_user.id,
|
||||
)
|
||||
|
||||
if template_id is not None:
|
||||
from backend.app.models.simulation_template import SimulationTemplate
|
||||
|
||||
tmpl = db.session.get(SimulationTemplate, template_id)
|
||||
if tmpl is None:
|
||||
return jsonify({"error": "Template not found"}), 404
|
||||
sim.description = tmpl.description
|
||||
sim.commands = tmpl.commands
|
||||
sim.prerequisites = tmpl.prerequisites
|
||||
sim.techniques = list(tmpl.techniques or [])
|
||||
sim.tactic_ids = list(tmpl.tactic_ids or [])
|
||||
|
||||
db.session.add(sim)
|
||||
db.session.commit()
|
||||
return jsonify(serialize_simulation(sim)), 201
|
||||
|
||||
143
backend/app/api/templates.py
Normal file
143
backend/app/api/templates.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""SimulationTemplate CRUD endpoints — admin and redteam only."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import sqlalchemy.exc
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from backend.app.auth import role_required
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models.simulation_template import SimulationTemplate
|
||||
from backend.app.serializers import serialize_template
|
||||
from backend.app.services import mitre as mitre_svc
|
||||
from backend.app.services.simulation_workflow import (
|
||||
_resolve_tactic_ids,
|
||||
_resolve_technique_ids,
|
||||
)
|
||||
|
||||
templates_bp = Blueprint("templates", __name__)
|
||||
|
||||
_MUTABLE_FIELDS = {"name", "description", "commands", "prerequisites", "technique_ids", "tactic_ids"}
|
||||
|
||||
|
||||
@templates_bp.get("/api/templates")
|
||||
@role_required("admin", "redteam")
|
||||
def list_templates():
|
||||
items = SimulationTemplate.query.order_by(SimulationTemplate.name).all()
|
||||
return jsonify([serialize_template(t) for t in items]), 200
|
||||
|
||||
|
||||
@templates_bp.post("/api/templates")
|
||||
@role_required("admin", "redteam")
|
||||
def create_template():
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
|
||||
techniques: list[dict] = []
|
||||
tactic_ids_val: list[str] = []
|
||||
|
||||
if "technique_ids" in data:
|
||||
if not mitre_svc.mitre_loaded:
|
||||
return jsonify({"error": "mitre bundle not loaded"}), 503
|
||||
resolved, err = _resolve_technique_ids(data["technique_ids"])
|
||||
if err is not None:
|
||||
return err
|
||||
techniques = resolved or []
|
||||
|
||||
if "tactic_ids" in data:
|
||||
resolved_ta, err = _resolve_tactic_ids(data["tactic_ids"])
|
||||
if err is not None:
|
||||
return err
|
||||
tactic_ids_val = resolved_ta or []
|
||||
|
||||
tmpl = SimulationTemplate(
|
||||
name=name,
|
||||
description=data.get("description"),
|
||||
commands=data.get("commands"),
|
||||
prerequisites=data.get("prerequisites"),
|
||||
techniques=techniques,
|
||||
tactic_ids=tactic_ids_val,
|
||||
created_at=datetime.now(UTC),
|
||||
created_by_id=g.current_user.id,
|
||||
)
|
||||
db.session.add(tmpl)
|
||||
try:
|
||||
db.session.commit()
|
||||
except sqlalchemy.exc.IntegrityError:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": "template name already exists"}), 409
|
||||
|
||||
return jsonify(serialize_template(tmpl)), 201
|
||||
|
||||
|
||||
@templates_bp.get("/api/templates/<int:tid>")
|
||||
@role_required("admin", "redteam")
|
||||
def get_template(tid: int):
|
||||
tmpl = db.session.get(SimulationTemplate, tid)
|
||||
if tmpl is None:
|
||||
return jsonify({"error": "Template not found"}), 404
|
||||
return jsonify(serialize_template(tmpl)), 200
|
||||
|
||||
|
||||
@templates_bp.patch("/api/templates/<int:tid>")
|
||||
@role_required("admin", "redteam")
|
||||
def update_template(tid: int):
|
||||
tmpl = db.session.get(SimulationTemplate, tid)
|
||||
if tmpl is None:
|
||||
return jsonify({"error": "Template not found"}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
unknown = set(data.keys()) - _MUTABLE_FIELDS
|
||||
if unknown:
|
||||
return jsonify({"error": f"unknown fields: {sorted(unknown)}"}), 400
|
||||
|
||||
if not data:
|
||||
return jsonify(serialize_template(tmpl)), 200
|
||||
|
||||
if "name" in data:
|
||||
name = (data["name"] or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name cannot be empty"}), 400
|
||||
tmpl.name = name
|
||||
|
||||
for field in ("description", "commands", "prerequisites"):
|
||||
if field in data:
|
||||
setattr(tmpl, field, data[field])
|
||||
|
||||
if "technique_ids" in data:
|
||||
if not mitre_svc.mitre_loaded:
|
||||
return jsonify({"error": "mitre bundle not loaded"}), 503
|
||||
resolved, err = _resolve_technique_ids(data["technique_ids"])
|
||||
if err is not None:
|
||||
return err
|
||||
tmpl.techniques = resolved
|
||||
|
||||
if "tactic_ids" in data:
|
||||
resolved_ta, err = _resolve_tactic_ids(data["tactic_ids"])
|
||||
if err is not None:
|
||||
return err
|
||||
tmpl.tactic_ids = resolved_ta
|
||||
|
||||
tmpl.updated_at = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except sqlalchemy.exc.IntegrityError:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": "template name already exists"}), 409
|
||||
|
||||
return jsonify(serialize_template(tmpl)), 200
|
||||
|
||||
|
||||
@templates_bp.delete("/api/templates/<int:tid>")
|
||||
@role_required("admin", "redteam")
|
||||
def delete_template(tid: int):
|
||||
tmpl = db.session.get(SimulationTemplate, tid)
|
||||
if tmpl is None:
|
||||
return jsonify({"error": "Template not found"}), 404
|
||||
db.session.delete(tmpl)
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
@@ -1,6 +1,15 @@
|
||||
"""SQLAlchemy models."""
|
||||
from backend.app.models.engagement import Engagement, EngagementStatus
|
||||
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||
from backend.app.models.simulation_template import SimulationTemplate
|
||||
from backend.app.models.user import User, UserRole
|
||||
|
||||
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus", "Simulation", "SimulationStatus"]
|
||||
__all__ = [
|
||||
"User",
|
||||
"UserRole",
|
||||
"Engagement",
|
||||
"EngagementStatus",
|
||||
"Simulation",
|
||||
"SimulationStatus",
|
||||
"SimulationTemplate",
|
||||
]
|
||||
|
||||
32
backend/app/models/simulation_template.py
Normal file
32
backend/app/models/simulation_template.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""SimulationTemplate model."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend.app.extensions import db
|
||||
|
||||
|
||||
class SimulationTemplate(db.Model): # type: ignore[name-defined]
|
||||
__tablename__ = "simulation_templates"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False, unique=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
commands = db.Column(db.Text, nullable=True)
|
||||
prerequisites = db.Column(db.Text, nullable=True)
|
||||
techniques = db.Column(db.JSON, nullable=False, default=list)
|
||||
tactic_ids = db.Column(db.JSON, nullable=False, default=list)
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at = db.Column(db.DateTime, nullable=True)
|
||||
created_by_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
created_by = db.relationship("User", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SimulationTemplate {self.id} {self.name!r}>"
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from backend.app.models import Engagement, User
|
||||
from backend.app.models.simulation import Simulation
|
||||
from backend.app.models.simulation_template import SimulationTemplate
|
||||
|
||||
|
||||
def serialize_user(user: User) -> dict[str, Any]:
|
||||
@@ -69,6 +70,23 @@ def serialize_simulation(simulation: Simulation) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def serialize_template(t: SimulationTemplate) -> dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"commands": t.commands,
|
||||
"prerequisites": t.prerequisites,
|
||||
"techniques": _enrich_techniques(t.techniques or []),
|
||||
"tactics": _enrich_tactics(t.tactic_ids or []),
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
||||
"created_by": serialize_user_brief(t.created_by) # type: ignore[arg-type]
|
||||
if t.created_by
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
|
||||
return {
|
||||
"id": engagement.id,
|
||||
|
||||
Reference in New Issue
Block a user