# Sprint 5 — Simulation templates **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. --- ## 0. SPEC.md à enrichir en début de sprint Ajouter une section `## Templates de simulations` (entre § Fonctionnement et § Authentification & rôles) : > 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). L'évolution est tracée dans CHANGELOG.md § Changed sprint 5. --- ## 1. User stories ### 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. **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/` (admin|redteam) → 200 ou 404. SOC → 403. - [ ] AC-26.6 : `PATCH /api/templates/` (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/` (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. ### US-27 — En tant que redteam, j'instancie un template dans un engagement **Pourquoi** : c'est le use-case principal des templates. **Critères d'acceptation** - [ ] AC-27.1 : `POST /api/engagements//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 ``. 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). ### 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). --- ## 2. Brief technique — Backend Builder **Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs, agents, scripts. ### Livrables **Modèle `SimulationTemplate`** (`backend/app/models/simulation_template.py` — nouveau fichier) ```python from datetime import UTC, datetime from sqlalchemy.orm import Mapped, mapped_column 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") ``` Ajouter à `backend/app/models/__init__.py`. **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/` (admin|redteam) → 200 ou 404. - `PATCH /api/templates/` (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/` (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//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`). --- ## 3. Brief technique — Frontend Builder **Scope strict** : `frontend/` uniquement. **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 Paths absolus dans le summary. Si auth flow ne marche pas → escalade. ### Livrables **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. --- ## 4. Brief — Test verifier 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) 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). --- ## 5. Definition of Done — Sprint 5 - [ ] 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é). --- ## 6. Décisions arrêtées (utilisateur 2026-05-28) 1. **Table** : `simulation_templates` séparée (clean schema, pas de colonnes nullable confuses). 2. **Instantiation API** : extension de `POST /api/engagements//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…). --- ## 7. Plan d'exécution 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`.