feat: sprint 6 — engagement export (md/csv/pdf) #9

Merged
knacky merged 20 commits from sprint/6-export into main 2026-06-09 16:19:02 +00:00
Showing only changes of commit 01434c04a7 - Show all commits

View File

@@ -1,300 +1,261 @@
# Sprint 5Simulation templates
# Sprint 6Engagement export (Markdown + CSV + PDF)
**Branche** : `sprint/5-templates`
**Statut** : 🟢 SPRINT COMPLET — backend 226/226 + frontend 121/121 + e2e 201/201, PR prête
**Base** : `main` @ `9873c53` (PR #7 sprint 4 mergé)
**Objectif** : permettre à un admin/redteam de créer des **templates de simulations** pré-remplies (RT-side : name, description, commands, prerequisites, techniques, tactics). Instancier un template dans un engagement crée une nouvelle simulation décorrélée (copie indépendante — éditer l'instance ne touche pas le template et vice-versa). User QA item 8 sprint 3.
> Branch : `sprint/6-export` · Worktree : `.claude/worktrees/sprint-6-export` · Base : `main` @ `678ee8f`
## §0 — Binding decisions (locked with the user 2026-06-07)
1. **Scope du sprint** : export d'un engagement (header + toutes ses simulations RT + SOC) vers Markdown, CSV et PDF — clôt la boucle « remplace l'utilisation d'un fichier excel plat partagé entre la redteam et les analystes SOC en fin de mission ».
2. **Formats livrés** : Markdown, CSV, PDF (3 formats). JSON exclu (redondant avec l'API existante).
3. **RBAC** : `admin` + `redteam` peuvent exporter. **SOC = pas d'accès** (pas de bouton dans l'UI, endpoint `/api/engagements/<eid>/export` → 403). Cohérent avec le pattern templates sprint 5 (livrable RedTeam).
4. **Contenu de l'export** : Engagement header (name, description, dates, status, created_by, created_at) + **toutes** les simulations de l'engagement, avec leurs champs RT (name, techniques, tactics, description, commands, prerequisites, executed_at, execution_result, status) ET SOC (log_source, logs, soc_comment, incident_number). Ordre des simulations : `id ASC` (ordre de création).
5. **Déclenchement UI** : un bouton **split-button dropdown** sur `EngagementDetailPage` libellé `[Export ▼]`, qui ouvre un menu `Markdown / CSV / PDF`. Click → download direct (Blob + `URL.createObjectURL`). Pas de modal de configuration. Pattern réutilisé du dropdown sprint 5 (`SimulationList`).
### Décisions techniques arrêtées par le team-lead (à challenger par spec-reviewer)
6. **Endpoint backend** : **un seul** endpoint `GET /api/engagements/<eid>/export?format=md|csv|pdf` plutôt que 3 endpoints distincts. Une seule route à protéger (RBAC), un seul test d'intégration RBAC, switch sur `format` en interne. Format inconnu → **400** `{error: "format must be one of: md, csv, pdf"}`. Format manquant → **400** (pas de défaut implicite — évite l'ambiguïté).
7. **Markdown** : généré via string templating Python (pas de lib externe). Simple, déterministe, testable par assertion de sous-chaînes.
8. **CSV** : généré via `csv.writer` (stdlib). Une ligne d'en-tête + N lignes simulations. Colonnes : `id, name, status, techniques (joined "|"), tactics (joined "|"), description, commands, prerequisites, executed_at, execution_result, log_source, logs, soc_comment, incident_number, created_at, updated_at`. **Pas de header engagement dans le CSV** (format machine-readable strict) ; l'engagement context sort dans le filename.
9. **PDF** : généré via **WeasyPrint** (Python HTML→PDF, lib mature, qualité de rendu pro, dépendances système cairo/pango/gdk-pixbuf à ajouter au `python:3.12-slim` du Dockerfile). Pipeline : on génère **le même HTML** que pour le Markdown (mais wrappé en `<html>...<style>...</html>`), puis WeasyPrint le rend en PDF. Le styling CSS est inline (≤ 30 lignes : hierarchy h1/h2/h3, code-block monospace, alternance fond pour les simulations). Pas de logo / page de garde — keep it simple.
10. **Filename convention** : `engagement-<id>-<slugified-name>-YYYYMMDD.{ext}`. Slugification = `re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')[:60]`. `YYYYMMDD` = `date.today().strftime('%Y%m%d')` côté serveur. Le frontend lit `Content-Disposition` pour le nom du fichier.
11. **Content-Type** : `text/markdown; charset=utf-8`, `text/csv; charset=utf-8`, `application/pdf`.
12. **Génération synchrone** : Flask renvoie le fichier dans la même requête (un engagement = quelques dizaines de simulations max, génération < 500 ms même PDF). Pas de job async.
13. **Pas de cache** : chaque export régénère depuis la DB (état toujours frais).
14. **Frontend client** : pour télécharger, on utilise un fetch avec `responseType: 'blob'`, on lit `Content-Disposition` pour le filename, puis `URL.createObjectURL` + `<a>` invisible + `click()`. Pas de navigation. Le bouton `[Export ▼]` partage l'esthétique du `[+ New ▼]` du sprint 5 (`bg-canvas`, `text-ink`, `border`, `rounded`, `shadow-soft-lift` + `dark:shadow-soft-lift-dark`).
### Points OUVERTS pour le spec-reviewer (à valider Pass 1)
- **WeasyPrint vs alternatives** : retenu pour rendu pro + pipeline HTML mutualisable. Alternatives écartées : `reportlab` (layout programmatique = beaucoup plus de code), `xhtml2pdf` (rendu inférieur), `pdfkit + wkhtmltopdf` (binaire externe en archive partielle). Le coût Dockerfile (≈ 50 MB de libs cairo/pango) est accepté.
- **CSV sans header engagement** : choix de pureté tabulaire (Excel-friendly direct). Le team-lead a tranché. Spec-reviewer doit confirmer ou proposer la variante "1 ligne commentaire `# Engagement: <name>`".
- **Pas de JSON export** : redondant avec l'API. À confirmer.
- **Statut `done` inclus comme tous les autres** : pas de filtre par défaut. L'utilisateur exporte toujours TOUT.
---
## 0. SPEC.md à enrichir en début de sprint
## §1 — Backend (Sonnet · backend-builder)
Ajouter une section `## Templates de simulations` (entre § Fonctionnement et § Authentification & rôles) :
### Modèle de données
**Aucun changement** de modèle. Pas de migration. L'export est en lecture seule sur les modèles existants `Engagement` + `Simulation`.
> Un **template de simulation** est une simulation pré-remplie côté redteam (name + description + commandes + pré-requis + techniques MITRE + tactiques MITRE) qui sert de point de départ pour instancier rapidement des simulations dans un engagement. Le template ne contient PAS de partie SOC, ni de date d'exécution, ni de résultat d'exécution — ces champs restent par-instance. L'instanciation d'un template dans un engagement crée une **nouvelle simulation indépendante** : le template et l'instance sont décorrélés, l'édition de l'un n'affecte pas l'autre. **Templates = ressource red team** : admin et redteam les gèrent (CRUD). SOC n'y a aucun accès (ni lecture, ni écriture, pas de nav link).
### Services / serializers
- Nouveau module **`backend/app/services/export.py`** avec 3 fonctions pures testables unitairement :
- `render_engagement_markdown(engagement: Engagement, simulations: list[Simulation]) -> str`
- `render_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> str`
- `render_engagement_pdf(engagement: Engagement, simulations: list[Simulation]) -> bytes`
- Le rendu Markdown réutilise `_enrich_techniques` + `_enrich_tactics` de `serializers.py` pour avoir `[{id, name}]` au lieu de juste les IDs.
- Le rendu PDF construit l'HTML à partir d'un template Python `_render_engagement_html(engagement, simulations) -> str` (string templating, pas Jinja — KISS) et le passe à `weasyprint.HTML(string=html).write_pdf()`.
L'évolution est tracée dans CHANGELOG.md § Changed sprint 5.
### Endpoint
- Extension du blueprint `engagements_bp` existant. Path : `GET /api/engagements/<int:eid>/export?format=md|csv|pdf`.
- Décorateur : `@role_required("admin", "redteam")`.
- Logique :
1. Charger l'engagement (404 si absent).
2. Parse `format` query param. Format manquant ou inconnu → 400 `{error: "format must be one of: md, csv, pdf"}`.
3. Charger les simulations triées par `id ASC`.
4. Appeler la fonction `render_engagement_<fmt>(engagement, simulations)`.
5. Construire la `Response` avec `Content-Type`, `Content-Disposition: attachment; filename="<slug>.<ext>"`, et le body.
- Filename helper : `_export_filename(engagement, ext) -> str` (slugifier + date).
### Tests
**Cible : 226 → 245+ pytest passing.**
Fichiers nouveaux :
- `backend/tests/test_export_engagement.py` — couvre l'endpoint + RBAC + format inconnu.
- `test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations`
- `test_export_markdown_redteam_ok`
- `test_export_markdown_soc_403`
- `test_export_csv_returns_csv_with_one_row_per_simulation`
- `test_export_csv_columns_match_contract` (assert exact header row)
- `test_export_csv_escapes_special_characters` (commands avec virgule, guillemet, newline)
- `test_export_pdf_returns_pdf_magic_bytes_and_non_empty`
- `test_export_unknown_format_400`
- `test_export_missing_format_400`
- `test_export_unknown_engagement_404`
- `test_export_engagement_with_zero_simulations_renders_header_only`
- `test_export_unauthenticated_401`
- `test_export_filename_slugifies_name_and_carries_date`
- `backend/tests/test_export_render.py` — tests unitaires sur les 3 fonctions pures.
- `test_render_engagement_markdown_includes_header_fields`
- `test_render_engagement_markdown_lists_all_simulations_in_order`
- `test_render_engagement_markdown_includes_techniques_with_id_and_name`
- `test_render_engagement_markdown_includes_tactics`
- `test_render_engagement_markdown_includes_soc_fields_even_when_blank` (cohérence handoff)
- `test_render_engagement_markdown_escapes_backticks_in_commands` (fenced code block safety)
- `test_render_engagement_csv_has_header_row`
- `test_render_engagement_csv_joins_multi_techniques_with_pipe`
- `test_render_engagement_pdf_starts_with_pdf_magic` (assert `output[:4] == b'%PDF'`)
### Dépendances
- `weasyprint>=60.0` ajouté à `backend/requirements.txt`.
- `docker/Dockerfile` stage Python : ajouter `apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info` (liste minimale WeasyPrint pour Debian slim). Documenter dans le PR.
### Livrable backend-builder (summary attendu)
- Tous les fichiers créés/modifiés
- Contrat API précis (statuts, query params, headers de réponse) en table
- Liste des helpers réutilisés (`_enrich_techniques`, `_enrich_tactics`, `serialize_user_brief`)
- **Section "Déviations vs plan"** explicite (cf. lesson sprint 5 — URL drift silencieuse à interdire)
- Résultats pytest + ruff + mypy
---
## 1. User stories
## §2 — Frontend (Sonnet · frontend-builder)
### US-26 — En tant qu'admin/redteam, je crée et gère des templates de simulations
**Pourquoi** : standardiser des simulations récurrentes (ex: "Mimikatz LSASS dump", "PowerShell empire stager") et éviter de retaper les mêmes commandes/MITRE à chaque engagement.
### Composants
- **`ExportEngagementButton.tsx`** (nouveau) : split-button dropdown style sprint 5.
- Bouton principal `Export` (icône `Download` lucide-react) + chevron à droite (icône `ChevronDown`).
- Click sur principal : ouvre le dropdown (pas d'action par défaut).
- Dropdown : 3 items "Markdown" / "CSV" / "PDF". Click → mutation download.
- Fermeture : click outside + Escape (réutiliser le hook/effet du dropdown sprint 5 dans `SimulationList`).
- Loading state : pendant la mutation, le composant affiche un spinner inline sur l'item cliqué, le dropdown reste ouvert. Désactive les 3 items pendant l'in-flight.
- Toast erreur sur 4xx/5xx.
- **`EngagementDetailPage.tsx`** : intégrer `<ExportEngagementButton engagementId={engagement.id} />` dans le header de la page, à côté des autres CTAs existants (edit/close). **Visible uniquement si `currentUser.role in ['admin', 'redteam']`** (gate côté UI + RBAC backend de toute façon en force).
**Critères d'acceptation**
- [ ] AC-26.1 : modèle `SimulationTemplate` (table `simulation_templates`) :
- `id` int PK
- `name` str NOT NULL UNIQUE (limite UX : un template unique par nom pour éviter les doublons dans le dropdown d'instanciation)
- `description` text nullable
- `commands` text nullable (chaîne multiligne, une commande par ligne, pattern sprint 2)
- `prerequisites` text nullable
- `techniques` JSON NOT NULL default `[]` (liste `[{id, name}]`, snapshot des techniques MITRE)
- `tactic_ids` JSON NOT NULL default `[]` (liste de TA-id strings)
- `created_at` datetime NOT NULL
- `updated_at` datetime nullable
- `created_by_id` int FK User
- [ ] AC-26.2 : migration Alembic `0005_simulation_templates.py` — CREATE TABLE simulation_templates + index sur `name`. Downgrade : DROP TABLE.
- [ ] AC-26.3 : `GET /api/templates` (admin|redteam) → liste `[{id, name, description, commands, prerequisites, techniques: [{id, name, tactics}], tactics: [{id, name}], created_at, created_by}]`, ordre `name ASC`. SOC → 403.
- [ ] AC-26.4 : `POST /api/templates` (admin|redteam) → 201 + template créé. Body : `{name, description?, commands?, prerequisites?, technique_ids?: [...], tactic_ids?: [...]}`. Valide : `name` non vide, name unique (409 si doublon), technique_ids / tactic_ids validés contre bundle MITRE / `_TACTIC_IDS` (réutilise les helpers `_resolve_technique_ids` / `_resolve_tactic_ids` sprint 3/4). SOC → 403.
- [ ] AC-26.5 : `GET /api/templates/<tid>` (admin|redteam) → 200 ou 404. SOC → 403.
- [ ] AC-26.6 : `PATCH /api/templates/<tid>` (admin|redteam) → 200, accepte les mêmes champs que POST en partial. Si `name` est modifié et entre en conflit avec un autre template → 409. SOC → 403.
- [ ] AC-26.7 : `DELETE /api/templates/<tid>` (admin|redteam) → 204. **Pas de cascade vers les simulations déjà instanciées** — celles-ci sont décorrélées et survivent. SOC → 403.
- [ ] AC-26.8 : page `/admin/templates` (admin|redteam uniquement) liste les templates en table (Name, MITRE count, Created by, Updated at, Actions). Boutons "New template" + "Edit" + "Delete". Tous les endpoints templates sont gated `@role_required("admin", "redteam")` côté backend, et ProtectedRoute frontend impose le même filtre.
### API client
- **`frontend/src/api/exports.ts`** (nouveau) :
- `downloadEngagementExport(engagementId: number, format: 'md' | 'csv' | 'pdf'): Promise<void>` — fait un GET `/api/engagements/<id>/export?format=<fmt>` avec `responseType: 'blob'`, lit `Content-Disposition` pour le filename, crée un `Blob` + `URL.createObjectURL` + `<a>.click()`, puis `URL.revokeObjectURL`.
- Helper `parseContentDispositionFilename(header: string | undefined): string | null` (regex `filename="..."`, fallback null).
### US-27 — En tant que redteam, j'instancie un template dans un engagement
**Pourquoi** : c'est le use-case principal des templates.
### Types
- Aucun nouveau type API (l'export retourne un Blob).
**Critères d'acceptation**
- [ ] AC-27.1 : `POST /api/engagements/<eid>/simulations` (admin|redteam) accepte un nouveau paramètre optionnel `template_id`. Si présent, le serveur valide que le template existe (404 sinon), puis crée une nouvelle simulation en copiant :
- `name` ← template.name (peut être override par `name` du body si fourni)
- `description` ← template.description
- `commands` ← template.commands
- `prerequisites` ← template.prerequisites
- `techniques` ← template.techniques (deep copy)
- `tactic_ids` ← template.tactic_ids (deep copy)
- Autres champs : status=pending, executed_at=null, execution_result=null, SOC fields=null
- [ ] AC-27.2 : `POST` sans `template_id` garde le comportement sprint 2 (création vierge avec juste `name`).
- [ ] AC-27.3 : la simulation créée depuis un template est **complètement décorrélée** : modifier l'instance ne touche pas le template, modifier le template ne touche pas les instances existantes. Pas de FK `template_id` stockée sur la simulation (clean decoupling).
- [ ] AC-27.4 : auto-transition pending → in_progress NE se déclenche PAS lors de la création depuis un template (même si le template a un name + description + techniques non vides). La création reste status=pending — la transition se fera au prochain PATCH explicite de la redteam. Cohérent avec règle sprint 2 "trigger sur PATCH" pas "trigger sur création".
- [ ] AC-27.5 : engagement auto-status n'est PAS déclenché par l'instanciation (status reste planned). Coherent avec AC-27.4.
- [ ] AC-27.6 : sur `EngagementDetailPage` (sprint 2/3/4), le bouton "+ New" (ou équivalent UI) ouvre désormais un menu / dropdown / modal avec 2 options : "Blank" et "From template…". L'option "From template…" affiche la liste des templates disponibles avec leur nom + un aperçu (count techniques/tactics). Click sur un template → POST avec `template_id` + redirection sur la simu créée. **Si `useTemplates()` retourne une liste vide → la modale affiche un `<EmptyState title="No templates available" description="Create one from the Templates page" />`. NE PAS désactiver l'option "From template…" dans le dropdown** (l'utilisateur doit pouvoir l'ouvrir pour comprendre qu'il n'y a rien — un disabled item silencieux serait confus).
- [ ] AC-27.7 : SOC n'a PAS accès au bouton d'instanciation (cohérent avec RBAC simulation creation sprint 2).
### Tests
**Cible : 121 → 130+ vitest passing.**
### US-28 — En tant qu'admin/redteam, j'accède aux templates depuis la nav
**Critères d'acceptation**
- [ ] AC-28.1 : `Layout.tsx` topbar nav contient un nouveau lien "Templates" (visible **uniquement à admin + redteam**). Pour SOC : le lien n'apparaît pas (cohérent avec "Users" qui est admin-only et masqué côté SOC).
- [ ] AC-28.2 : `ProtectedRoute` pour `/admin/templates` impose `roles=["admin", "redteam"]`. SOC qui tente d'y accéder en tapant l'URL → redirigé vers `/engagements` + toast "Accès refusé" (pattern existant ProtectedRoute sprint 1).
- [ ] AC-28.3 : la page `/admin/templates` n'inclut PAS de mode "read-only SOC" — elle est strictement admin+redteam. Les composants peuvent assumer `canEditTemplates = isAdmin || isRedteam = true` (toujours vrai à ce niveau).
Fichiers nouveaux :
- `frontend/tests/ExportEngagementButton.test.tsx`
- `renders Export button with chevron`
- `clicking primary opens dropdown with three formats`
- `clicking outside closes dropdown`
- `Escape closes dropdown`
- `clicking Markdown triggers download with format=md`
- `clicking CSV triggers download with format=csv`
- `clicking PDF triggers download with format=pdf`
- `loading state disables items during in-flight`
- `error response shows toast`
- `frontend/tests/EngagementDetailPage.test.tsx` (existant — adapter) :
- `admin sees Export button`
- `redteam sees Export button`
- `soc does NOT see Export button`
### Screenshots OBLIGATOIRES (lesson sprint 4)
- `EngagementDetailPage` light + dark, dropdown fermé.
- `EngagementDetailPage` light + dark, dropdown ouvert (3 items visibles).
- `EngagementDetailPage` SOC view — bouton Export ABSENT.
- Le builder doit fournir un script Playwright authenti (réutiliser le pattern sprint 5 — `page.goto('/login') → fill → wait nav`).
### Livrable frontend-builder (summary attendu)
- Tous les fichiers créés/modifiés
- API contracts consommés exactement comme livrés par backend (cf. lesson sprint 5 — path drift à éviter, grep `Content-Disposition` dans la PR)
- Helpers réutilisés (`useToast`, etc.)
- Résultats vitest + typecheck + lint
- Liste des écrans capturés (light + dark, role-by-role)
---
## 2. Brief technique — Backend Builder
## §3 — Acceptance tests (Sonnet · test-verifier)
**Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs, agents, scripts.
**Cible : 201 → 215+ Playwright passing.**
### Livrables
3 user stories à couvrir :
**Modèle `SimulationTemplate`** (`backend/app/models/simulation_template.py` — nouveau fichier)
```python
from datetime import UTC, datetime
from sqlalchemy.orm import Mapped, mapped_column
### US-29 — Admin/redteam exporte l'engagement en Markdown/CSV/PDF
- **AC-29.1** : login admin → engagement avec ≥ 2 simulations → click "Export" → dropdown s'ouvre.
- **AC-29.2** : click "Markdown" → download d'un `.md` avec `Content-Type: text/markdown`. Le fichier contient le nom de l'engagement, la date de début, et le nom de chaque simulation.
- **AC-29.3** : click "CSV" → download d'un `.csv` avec exactement N+1 lignes (1 header + N simulations). La colonne `name` contient les noms des simulations.
- **AC-29.4** : click "PDF" → download avec `Content-Type: application/pdf`, taille > 1 KB, magic bytes `%PDF`.
- **AC-29.5** : login redteam → mêmes 3 formats fonctionnent.
- **AC-29.6** : filename respecte `engagement-<id>-<slug>-YYYYMMDD.{ext}` (assert via Content-Disposition).
class SimulationTemplate(db.Model):
__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"), nullable=False)
created_by = db.relationship("User")
```
### US-30 — SOC pas d'accès à l'export
- **AC-30.1** : login SOC → engagement page → bouton "Export" **ABSENT** du DOM (pas seulement `display: none`).
- **AC-30.2** : appel direct API `GET /api/engagements/<id>/export?format=md` (Bearer SOC) → 403.
- **AC-30.3** : (sanity) appel API sans token → 401.
Ajouter à `backend/app/models/__init__.py`.
### US-31 — Robustesse format / engagement
- **AC-31.1** : `GET /api/engagements/<id>/export` sans `format` → 400 message friendly.
- **AC-31.2** : `GET /api/engagements/<id>/export?format=xml` → 400 friendly.
- **AC-31.3** : `GET /api/engagements/99999/export?format=md` → 404.
- **AC-31.4** : engagement avec 0 simulations → export OK (header seul, le CSV n'a qu'une ligne d'en-tête, le MD n'a pas de section simulation).
**Migration Alembic `0005_simulation_templates.py`**
- Upgrade : `op.create_table("simulation_templates", ...)` avec tous les champs et la contrainte UNIQUE sur `name`. Pas besoin de batch (CREATE TABLE est natif SQLite).
- Downgrade : `op.drop_table("simulation_templates")`. SQLite OK natif.
- Pas de backfill (table vide à la création).
**Serializer** (`backend/app/serializers.py`)
- Nouvelle fonction `serialize_template(t)` :
```python
return {
"id": t.id,
"name": t.name,
"description": t.description,
"commands": t.commands,
"prerequisites": t.prerequisites,
"techniques": _enrich_techniques(t.techniques), # réutilise sprint 3
"tactics": _enrich_tactics(t.tactic_ids), # réutilise sprint 4
"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) if t.created_by else None,
}
```
- Pattern parallèle à `serialize_simulation` mais SANS les champs SOC / status / executed_at.
**API** (`backend/app/api/templates.py` — nouveau blueprint)
**Tous les endpoints templates sont gated `@role_required("admin", "redteam")` — SOC reçoit 403 partout.**
- `GET /api/templates` (admin|redteam) → liste serializée, tri `name ASC`.
- `POST /api/templates` (admin|redteam) → création. Validation : `name` non vide (400), `technique_ids` / `tactic_ids` valides (réutilise `_resolve_technique_ids` / `_resolve_tactic_ids` de `simulation_workflow.py` — import direct, KISS). Pour le `name` UNIQUE conflict : **catch `sqlalchemy.exc.IntegrityError` sur INSERT → 409 `{"error": "template name already exists"}`**. Pas de pre-check SELECT (race condition + code mort, la contrainte UNIQUE en DB est l'autorité).
- `GET /api/templates/<tid>` (admin|redteam) → 200 ou 404.
- `PATCH /api/templates/<tid>` (admin|redteam) → update partial. Pour le `name` conflict : même pattern, **catch IntegrityError sur UPDATE → 409 `{"error": "template name already exists"}`**. Cas edge : PATCH avec `name == current_name` (no-op rename) → 200 (l'UPDATE sur sa propre row ne viole pas UNIQUE). Mettre à jour `updated_at`.
- `DELETE /api/templates/<tid>` (admin|redteam) → 204. Pas de cascade FK simulations (les simulations n'ont pas de `template_id` FK).
Enregistrer le blueprint dans `backend/app/__init__.py`.
**Modification `POST /api/engagements/<eid>/simulations`** (`backend/app/api/simulations.py`)
- Le payload accepte maintenant un `template_id` optionnel.
- Si présent :
- Charger le template (404 si non trouvé).
- Créer la simulation en **setant DIRECTEMENT les champs RT** sur l'objet ORM Simulation à partir des champs du template (`sim.techniques = template.techniques`, `sim.tactic_ids = template.tactic_ids`, `sim.description = template.description`, `sim.commands = template.commands`, `sim.prerequisites = template.prerequisites`).
- `name` du body override si fourni, sinon `template.name`.
- **NE PAS appeler `apply_patch()`, `_resolve_technique_ids()`, ni `_resolve_tactic_ids()`** — les données viennent du template déjà persisté+validé, re-résoudre frapperait inutilement le bundle MITRE ET déclencherait l'auto-transition pending→in_progress via la logique auto-trigger de `apply_patch`, ce qui violerait AC-27.4. Le set direct ORM est intentionnellement court-circuité.
- Si absent : comportement actuel inchangé (création vierge avec `name`).
- Auto-transition NE PAS déclencher (status reste pending — règle sprint 2 "trigger sur PATCH", la création ne compte pas). Le fait de ne pas appeler `apply_patch` est ce qui garantit ça structurellement.
- Engagement auto-status NE PAS déclencher (status engagement reste planned). Idem — `_maybe_activate_engagement` n'est appelé que depuis `apply_patch`.
**Tests pytest**
- `test_simulation_templates_crud.py` (nouveau) :
- GET liste vide, GET liste après création, GET liste tri name ASC.
- POST valide → 201, fields persisted.
- POST name vide → 400.
- POST name dupliqué → 409.
- POST technique_id inconnu → 400.
- POST tactic_id inconnu → 400.
- POST par SOC → 403.
- GET inexistant → 404.
- PATCH valide → 200, updated_at set.
- PATCH name → conflit → 409.
- PATCH par SOC → 403.
- DELETE valide → 204, GET ensuite → 404.
- DELETE par SOC → 403.
- `test_simulations_from_template.py` (nouveau) :
- POST simulation avec template_id valide → copie tous les RT fields, status=pending, executed_at=null, SOC fields=null.
- POST avec template_id valide + name override → name override gagne.
- POST avec template_id inexistant → 404.
- POST avec template_id par SOC → 403 (cohérent avec création).
- Vérifier décorrélation : créer template → instancier → modifier l'instance → assert template inchangé. Symétrique : modifier le template → instance inchangée.
- Auto-transition NE PAS déclenchée (sim reste pending même si template avait des techniques).
- Engagement reste planned (auto-status NOT triggered).
- Migration test : `0005` create/drop round-trip propre (réutilise pattern Alembic round-trip sprint 3/4 avec `Path(__file__)`).
**Quality bar** : ruff + mypy clean, tous tests existants + nouveaux verts.
### Règles
- Pas de touche au frontend, e2e, agents, scripts, Makefile.
- Renvoyer le summary attendu (cf. `.claude/agents/backend-builder.md`).
### Bouncing
- Si un AC échoue → bounce au builder responsable (backend ou frontend), pas de patch test-side.
---
## 3. Brief technique — Frontend Builder
## §4 — Reviews
**Scope strict** : `frontend/` uniquement.
### Spec-reviewer Pass 1 (avant dispatch)
- Lit ce `tasks/todo.md` § 0 + § 1 + § 2 + § 3.
- Verdict attendu : APPROVED / NEEDS-CHANGES par section.
- Points particuliers à challenger : WeasyPrint vs reportlab, CSV sans header engagement, URL drift (un seul endpoint avec query param vs 3 endpoints distincts).
**Screenshots MANDATORY (lesson sprint 4)** : à la fin de ton travail, dev server + auth flow (page.goto('/login') + fill creds + submit + wait) pour fournir MIN 6 screenshots :
1. `/admin/templates` liste (admin OU redteam vue, ≥ 2 templates) — light + dark
2. Template create/edit form (mode edit avec techniques + tactic chips) — light + dark
3. EngagementDetail avec dropdown "Blank | From template…" ouvert — light
4. TemplatePickerModal ouverte (au moins 2 templates listés) — light
5. TemplatePickerModal ouverte avec aucun template — empty state visible — light
6. Simulation créée depuis un template (champs pré-remplis avec le nom, MITRE chips) — light
### Spec-reviewer Pass 2 (après mes éventuels édits du plan)
- Re-validation des changements apportés.
- **TEAM-LEAD : ne PAS dispatcher backend tant que Pass 2 n'a pas répondu APPROVED.** Lesson sprint 5 — la patience sur le 2-pass a éliminé les addenda mid-implementation.
Paths absolus dans le summary. Si auth flow ne marche pas → escalade.
### Code-reviewer (après backend + frontend)
- LSP first (`goToDefinition`, `findReferences`).
- Focalise sur : pureté des render functions (testables), gestion des deps WeasyPrint dans Dockerfile, échappement CSV, filename slug, dropdown close-on-outside réutilisation.
### Livrables
### Design-reviewer (après screenshots frontend)
- Light + dark cohérence du dropdown Export.
- Vérifie que le bouton respecte la convention "icône + label court ≤ 8 chars" (`Export`).
- Audit alignement vs le header existant de la page.
**Types** (`frontend/src/api/types.ts`)
- `SimulationTemplate`: `{id, name, description, commands, prerequisites, techniques: MitreTechnique[], tactics: MitreTactic[], created_at, updated_at, created_by}`.
- `SimulationTemplateCreateInput`: payload POST.
- `SimulationTemplatePatchInput`: payload PATCH.
- Étendre `SimulationCreateInput` avec `template_id?: number`.
**API client** (`frontend/src/api/templates.ts` — nouveau)
- `listTemplates()`, `getTemplate(id)`, `createTemplate(input)`, `updateTemplate(id, patch)`, `deleteTemplate(id)`.
**Hooks TanStack Query** (`frontend/src/hooks/useTemplates.ts` — nouveau)
- `useTemplates()`, `useTemplate(id)`, mutations `useCreateTemplate`, `useUpdateTemplate`, `useDeleteTemplate`.
- Invalidation : create/update/delete invalide `["templates"]` et `["templates", id]`.
**Pages**
- **`TemplatesListPage.tsx`** (nouveau, `/admin/templates`) — admin+redteam only :
- Table : Name, MITRE count (techniques + tactics), Created by, Updated at, Actions.
- Bouton "+ New template" en header.
- Actions par ligne : "Edit" + "Delete".
- Click sur une ligne → `/admin/templates/:id/edit`.
- States : loading / error / empty.
- **`TemplateFormPage.tsx`** (nouveau, `/admin/templates/new` et `/admin/templates/:id/edit`) — admin+redteam only :
- Form pour name + description + commands (textarea) + prerequisites + MitreTechniquesField.
- Mode `new` : seul `name` requis ; après création, redirige sur `/admin/templates/:id/edit`.
- Mode `edit` : load existing template, allow update.
- Bouton Delete (confirmation modal).
- Pas de mode read-only (SOC n'a pas accès aux routes).
- **`EngagementDetailPage.tsx`** (modification) :
- Remplacer le bouton simple "+ New simulation" par un dropdown OU un menu :
- "Blank" (action default)
- "From template…" → ouvre une modale avec la liste des templates.
- Modale "From template…" : `useTemplates()`, table simple Name + MITRE count, click sur un template → `useCreateSimulation` avec `template_id: t.id` → redirect sur la simu créée.
**Composants** (`frontend/src/components/`)
- **`TemplatePickerModal.tsx`** (nouveau) : modale qui liste les templates, permet de cliquer pour instancier. Props : `engagementId`, `onClose`, `onInstantiated(simId)`.
**Routing** (`App.tsx`) — toutes routes templates gated `roles=["admin", "redteam"]` :
- `/admin/templates` (admin|redteam)
- `/admin/templates/new` (admin|redteam)
- `/admin/templates/:id/edit` (admin|redteam)
**Layout** (`Layout.tsx`)
- Nouveau lien "Templates" dans la topbar, à droite de "Users" — **visible UNIQUEMENT à admin + redteam** (masqué pour SOC, pattern identique à "Users" qui est admin-only).
**Tests Vitest**
- `TemplatesListPage.test.tsx` — loading/error/empty + boutons New/Edit/Delete présents (admin|redteam — pas de variante soc puisque route inaccessible).
- `TemplateFormPage.test.tsx` — mode new + mode edit (pas de mode read-only).
- `TemplatePickerModal.test.tsx` — liste templates, click instantiate, gestion erreur, close.
- `EngagementDetailPage.test.tsx` — adapter pour le nouveau dropdown "+ New simulation".
### Règles
- Lit le summary backend EN PREMIER.
- Pas d'invention d'endpoints.
- Réutilise `LoadingState`, `ErrorState`, `EmptyState`, `Toast`, `ConfirmDialog`, `MitreTechniquesField`, `StatusBadge` etc.
- Respect DESIGN.md tokens. Dark mode déjà en place — applique les patterns sprint 4 (`bg-canvas dark:bg-canvas`, etc.).
- Pas de dépendance npm sans escalade.
### Test-verifier (après code-reviewer APPROVED)
- Écrit 1 spec file par US (`us29-export-formats`, `us30-export-rbac`, `us31-export-robustness`).
- Rapport pass/fail par AC.
---
## 4. BriefTest verifier
## §5 — SPEC.md update (au tout début du sprintlesson sprint 3/4/5)
E2e Playwright. Un fichier par US :
- `us26-templates-crud.spec.ts` — AC-26.1 → AC-26.8 (focus API + UI gérance templates)
- `us27-instantiate-from-template.spec.ts` — AC-27.1 → AC-27.7 (création simu depuis template, décorrélation)
- `us28-templates-nav.spec.ts` — AC-28.1 → AC-28.3 (nav link, accès SOC read-only)
Ajouter une section **§ Export d'engagement** entre § Templates de simulations et § Authentification & rôles :
Adapter les sprint 2/3/4 e2e si l'ajout du dropdown "+ New simulation" casse des sélecteurs (les tests sprint 2/3 cliquent directement sur "+ New" — désormais ça ouvre un menu avant d'aller au form blanc).
> ## Export d'engagement
> Un engagement peut être exporté à tout moment dans 3 formats au choix : **Markdown** (handoff narratif), **CSV** (machine-readable, intégration tableurs), **PDF** (livrable client). L'export contient l'en-tête de l'engagement et toutes ses simulations avec les champs Red Team **et** SOC. Endpoint unique : `GET /api/engagements/<id>/export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement-<id>-<slug>-YYYYMMDD.<ext>`.
**Le commit qui crée cette section doit être le PREMIER commit du sprint** (pas le dernier — sinon on rate le bug récurrent identifié dans les lessons). Le commit suivant peut être le plan lui-même (`tasks/todo.md` + `tasks/lessons.md`).
---
## 5. Definition of Done — Sprint 5
## §6 — Workflow git du sprint
- [ ] Tous les AC US-26 → US-28 passent.
- [ ] Backend pytest verts (~193 existants + ~25 nouveaux). Ruff + mypy clean.
- [ ] Frontend vitest verts (92 existants + nouveaux). Typecheck + lint clean.
- [ ] E2e Playwright suite verte (sprint 1-4 + sprint 5).
- [ ] Migration 0005 testée via Alembic round-trip.
- [ ] SPEC.md § Templates de simulations ajoutée.
- [ ] README.md mis à jour si nouveau bullet "Templates" pertinent.
- [ ] CHANGELOG.md sprint 5 entry sous [Unreleased].
- [ ] **Design-reviewer pass** sur les nouveaux écrans (lesson sprint 4 — design-reviewer = part of workflow depuis sprint 4).
- [ ] Code-reviewer sans BLOCKER.
- [ ] PR via `make open-pr` (sprint 4 dogfood validé).
- Branch : `sprint/6-export` (créée @ `678ee8f`).
- Commit séquence :
1. `docs(spec): add § Export d'engagement section` (le `M SPEC.md` ne doit JAMAIS rester unstaged)
2. `docs(plan): sprint 6 plan + sprint-5 lessons folded` (tasks/)
3. Commits backend (un ou deux, signés par backend-builder)
4. Commits frontend (un ou deux, signés par frontend-builder)
5. Commit post-code-review fixes (si nécessaire)
6. Commit screenshots design + e2e tests
7. Wrap-up commit team-lead : CHANGELOG + README + lessons.md sprint-6 + plan final
- PR via `make open-pr SPRINT=6 TITLE="feat: sprint 6 — engagement export (md/csv/pdf)" BODY=tasks/pr-body-sprint-6.md` (3e dogfood du wrapper sprint 4).
---
## 6. Décisions arrêtées (utilisateur 2026-05-28)
## §7 — Risk / hazard list
1. **Table** : `simulation_templates` séparée (clean schema, pas de colonnes nullable confuses).
2. **Instantiation API** : extension de `POST /api/engagements/<eid>/simulations` avec `template_id` optionnel.
3. **Name uniqueness** : UNIQUE (1 template par nom, UX dropdown clean).
4. **Template RBAC** : admin + redteam writable. **SOC pas d'accès du tout** — pas de nav link, pas de page, tous endpoints templates → 403. Templates sont une ressource Red Team uniquement.
5. **UI instanciation** : dropdown sur le bouton "+ New simulation" (Blank | From template…).
| # | Risk | Mitigation |
|---|---|---|
| 1 | WeasyPrint deps gonflent l'image Docker | Liste minimale documentée + WeasyPrint déjà packagé sur Debian slim ; mesurer Δ MB image build après vs avant |
| 2 | CSV mal-échappé avec commands multilines / quotes | Utiliser `csv.writer` stdlib (handles tout automatiquement), pas de string concat manuel |
| 3 | Markdown casse sur backticks dans commands | Fenced code blocks `~~~bash` (tildes au lieu de backticks pour les blocks contenant des backticks), OU escape via `markdown.escape` |
| 4 | Test PDF fragile sur le contenu | Asserter UNIQUEMENT : Content-Type, magic bytes `%PDF`, taille > 1 KB. Pas de regex sur le texte rendu (binary). |
| 5 | URL drift backend (`/export` vs `/engagements/<id>/export`) | Lesson sprint 5 — la 1re ligne du backend summary doit confirmer le path exact |
| 6 | Frontend oublie `URL.revokeObjectURL` → fuite mémoire | Test unitaire explicite : assert `revokeObjectURL` appelé après le click téléchargement |
| 7 | SPEC.md uncommitted à la fin du sprint (3 sprints en série !) | Commit SPEC.md en commit #1 du sprint, pas en wrap-up. Étape « cendrillon » du plan ci-dessus. |
---
## 7. Plan d'exécution
## §8 — Definition of Done (sprint-level)
1. ✅ User a validé les 5 décisions §6 (2026-05-28). SOC zero access acté.
2. 🟡 Team-lead met à jour SPEC.md (§0).
3. 🟡 Spec-reviewer valide le plan (2-pass — lesson sprint 3/4 — RBAC SOC blocked, name unique conflict 409 handling, template_id passing through auto-transition, design-reviewer scope new pages).
4. 🔵 Backend-builder : modèle + migration 0005 + endpoints + tests.
5. 🔵 Frontend-builder : pages Templates list/form + TemplatePickerModal + nav link + dropdown engagement + tests Vitest. Screenshots mandatory.
6. 🔵 Design-reviewer : revoit diff frontend + screenshots.
7. 🔵 Code-reviewer : LSP-first review du diff complet.
8. 🔵 Test-verifier : e2e US-26 → US-28.
9. 🟢 Team-lead : `make open-pr` + récap.
Branche : `sprint/5-templates`.
- [ ] §5 SPEC.md committed AS THE FIRST COMMIT of the sprint.
- [ ] Backend : 245+ pytest, ruff clean, mypy clean.
- [ ] Frontend : 130+ vitest, typecheck clean, lint clean.
- [ ] E2e : 215+ Playwright, 0 régression vs main.
- [ ] Screenshots fournies : EngagementDetailPage light + dark, dropdown fermé + ouvert, vue SOC sans bouton.
- [ ] Dockerfile mis à jour avec deps WeasyPrint + `make build` réussit.
- [ ] CHANGELOG.md `[Unreleased] → Sprint 6` rédigée.
- [ ] README.md « Status » bumped + section dans le tableau des features si pertinent.
- [ ] PR ouverte via `make open-pr` (pas via UI manuelle).
- [ ] `git status` au sprint-close affiche **uniquement** des fichiers ignorés (lesson récurrente).