feat: sprint 1 — auth + CRUD engagements
Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.
Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling
Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
for already-authed users, Fragment-keyed admin user rows)
Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data
Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)
Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
resolves the `backend.app.*` absolute imports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:37:53 +02:00
|
|
|
"""Engagement CRUD endpoints."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import date
|
|
|
|
|
|
2026-06-08 17:57:22 +02:00
|
|
|
from flask import Blueprint, Response, g, jsonify, request
|
feat: sprint 1 — auth + CRUD engagements
Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.
Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling
Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
for already-authed users, Fragment-keyed admin user rows)
Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data
Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)
Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
resolves the `backend.app.*` absolute imports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:37:53 +02:00
|
|
|
|
|
|
|
|
from backend.app.auth import login_required, role_required
|
|
|
|
|
from backend.app.extensions import db
|
|
|
|
|
from backend.app.models import Engagement, EngagementStatus
|
2026-06-08 17:57:22 +02:00
|
|
|
from backend.app.models.simulation import Simulation
|
feat: sprint 1 — auth + CRUD engagements
Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.
Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling
Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
for already-authed users, Fragment-keyed admin user rows)
Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data
Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)
Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
resolves the `backend.app.*` absolute imports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:37:53 +02:00
|
|
|
from backend.app.serializers import serialize_engagement
|
2026-06-08 17:57:22 +02:00
|
|
|
from backend.app.services.export import (
|
|
|
|
|
_export_filename,
|
|
|
|
|
render_engagement_csv,
|
|
|
|
|
render_engagement_markdown,
|
|
|
|
|
render_engagement_pdf,
|
|
|
|
|
)
|
feat: sprint 1 — auth + CRUD engagements
Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.
Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling
Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
for already-authed users, Fragment-keyed admin user rows)
Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data
Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)
Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
resolves the `backend.app.*` absolute imports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:37:53 +02:00
|
|
|
|
|
|
|
|
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_date(value: object) -> date | None:
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return date.fromisoformat(value)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_status(value: object) -> EngagementStatus | None:
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return EngagementStatus(value)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@engagements_bp.get("")
|
|
|
|
|
@login_required
|
|
|
|
|
def list_engagements():
|
|
|
|
|
items = Engagement.query.order_by(Engagement.id.asc()).all()
|
|
|
|
|
return jsonify([serialize_engagement(e) for e in items]), 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@engagements_bp.post("")
|
|
|
|
|
@role_required("admin", "redteam")
|
|
|
|
|
def create_engagement():
|
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
|
|
|
|
name = (data.get("name") or "").strip()
|
|
|
|
|
if not name:
|
|
|
|
|
return jsonify({"error": "name is required"}), 400
|
|
|
|
|
|
|
|
|
|
start_raw = data.get("start_date")
|
|
|
|
|
start_date = _parse_date(start_raw) if start_raw else None
|
|
|
|
|
if start_date is None:
|
|
|
|
|
return jsonify({"error": "start_date is required (YYYY-MM-DD)"}), 400
|
|
|
|
|
|
|
|
|
|
end_raw = data.get("end_date")
|
|
|
|
|
end_date: date | None = None
|
|
|
|
|
if end_raw:
|
|
|
|
|
end_date = _parse_date(end_raw)
|
|
|
|
|
if end_date is None:
|
|
|
|
|
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
|
|
|
|
if end_date < start_date:
|
|
|
|
|
return jsonify({"error": "end_date must be >= start_date"}), 400
|
|
|
|
|
|
|
|
|
|
status = EngagementStatus.PLANNED
|
|
|
|
|
if "status" in data and data.get("status") is not None:
|
|
|
|
|
parsed = _parse_status(data.get("status"))
|
|
|
|
|
if parsed is None:
|
|
|
|
|
return (
|
|
|
|
|
jsonify({"error": "status must be one of: planned, active, closed"}),
|
|
|
|
|
400,
|
|
|
|
|
)
|
|
|
|
|
status = parsed
|
|
|
|
|
|
|
|
|
|
engagement = Engagement(
|
|
|
|
|
name=name,
|
|
|
|
|
description=data.get("description"),
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
status=status,
|
|
|
|
|
created_by_id=g.current_user.id,
|
|
|
|
|
)
|
|
|
|
|
db.session.add(engagement)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
return jsonify(serialize_engagement(engagement)), 201
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@engagements_bp.get("/<int:engagement_id>")
|
|
|
|
|
@login_required
|
|
|
|
|
def get_engagement(engagement_id: int):
|
|
|
|
|
engagement = db.session.get(Engagement, engagement_id)
|
|
|
|
|
if engagement is None:
|
|
|
|
|
return jsonify({"error": "Engagement not found"}), 404
|
|
|
|
|
return jsonify(serialize_engagement(engagement)), 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@engagements_bp.patch("/<int:engagement_id>")
|
|
|
|
|
@role_required("admin", "redteam")
|
|
|
|
|
def update_engagement(engagement_id: int):
|
|
|
|
|
engagement = db.session.get(Engagement, engagement_id)
|
|
|
|
|
if engagement is None:
|
|
|
|
|
return jsonify({"error": "Engagement not found"}), 404
|
|
|
|
|
|
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
|
|
|
|
|
|
if "name" in data:
|
|
|
|
|
name = (data.get("name") or "").strip()
|
|
|
|
|
if not name:
|
|
|
|
|
return jsonify({"error": "name must not be empty"}), 400
|
|
|
|
|
engagement.name = name
|
|
|
|
|
|
|
|
|
|
if "description" in data:
|
|
|
|
|
engagement.description = data.get("description")
|
|
|
|
|
|
|
|
|
|
new_start = engagement.start_date
|
|
|
|
|
if "start_date" in data:
|
|
|
|
|
parsed = _parse_date(data.get("start_date"))
|
|
|
|
|
if parsed is None:
|
|
|
|
|
return jsonify({"error": "start_date must be YYYY-MM-DD"}), 400
|
|
|
|
|
new_start = parsed
|
|
|
|
|
|
|
|
|
|
new_end = engagement.end_date
|
|
|
|
|
if "end_date" in data:
|
|
|
|
|
if data.get("end_date") in (None, ""):
|
|
|
|
|
new_end = None
|
|
|
|
|
else:
|
|
|
|
|
parsed = _parse_date(data.get("end_date"))
|
|
|
|
|
if parsed is None:
|
|
|
|
|
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
|
|
|
|
new_end = parsed
|
|
|
|
|
|
|
|
|
|
if new_end is not None and new_end < new_start:
|
|
|
|
|
return jsonify({"error": "end_date must be >= start_date"}), 400
|
|
|
|
|
|
|
|
|
|
engagement.start_date = new_start
|
|
|
|
|
engagement.end_date = new_end
|
|
|
|
|
|
|
|
|
|
if "status" in data:
|
|
|
|
|
parsed_status = _parse_status((data.get("status") or "").strip())
|
|
|
|
|
if parsed_status is None:
|
|
|
|
|
return (
|
|
|
|
|
jsonify({"error": "status must be one of: planned, active, closed"}),
|
|
|
|
|
400,
|
|
|
|
|
)
|
|
|
|
|
engagement.status = parsed_status
|
|
|
|
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
return jsonify(serialize_engagement(engagement)), 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@engagements_bp.delete("/<int:engagement_id>")
|
|
|
|
|
@role_required("admin", "redteam")
|
|
|
|
|
def delete_engagement(engagement_id: int):
|
|
|
|
|
engagement = db.session.get(Engagement, engagement_id)
|
|
|
|
|
if engagement is None:
|
|
|
|
|
return jsonify({"error": "Engagement not found"}), 404
|
|
|
|
|
db.session.delete(engagement)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
return "", 204
|
2026-06-08 17:57:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@engagements_bp.get("/<int:eid>/export")
|
|
|
|
|
@role_required("admin", "redteam")
|
|
|
|
|
def export_engagement(eid: int):
|
|
|
|
|
engagement = db.session.get(Engagement, eid)
|
|
|
|
|
if engagement is None:
|
|
|
|
|
return jsonify({"error": "Engagement not found"}), 404
|
|
|
|
|
|
|
|
|
|
fmt = request.args.get("format", "").strip().lower()
|
|
|
|
|
if fmt not in ("md", "csv", "pdf"):
|
|
|
|
|
return jsonify({"error": "format must be one of: md, csv, pdf"}), 400
|
|
|
|
|
|
|
|
|
|
simulations = (
|
|
|
|
|
Simulation.query.filter_by(engagement_id=eid)
|
|
|
|
|
.order_by(Simulation.id.asc())
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if fmt == "md":
|
|
|
|
|
body = render_engagement_markdown(engagement, simulations)
|
|
|
|
|
filename = _export_filename(engagement, "md")
|
|
|
|
|
return Response(
|
|
|
|
|
body,
|
|
|
|
|
mimetype="text/markdown; charset=utf-8",
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if fmt == "csv":
|
|
|
|
|
body = render_engagement_csv(engagement, simulations)
|
|
|
|
|
filename = _export_filename(engagement, "csv")
|
|
|
|
|
return Response(
|
|
|
|
|
body,
|
|
|
|
|
mimetype="text/csv; charset=utf-8",
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# pdf
|
|
|
|
|
body_bytes = render_engagement_pdf(engagement, simulations)
|
|
|
|
|
filename = _export_filename(engagement, "pdf")
|
|
|
|
|
return Response(
|
|
|
|
|
body_bytes,
|
|
|
|
|
mimetype="application/pdf",
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
|
|
|
)
|