- CHANGELOG: sprint 5 entry under [Unreleased] (templates CRUD + instantiation + nav + dropdown + decorrelation). Sprint 4 moved to its own [Sprint 4] section. - README: status bump to sprint 5, test counts refreshed (226/121/201). - tasks/lessons.md: 6 sprint-5 lessons captured (spec-reviewer 2-pass before dispatch finally clicked, endpoint path drift caught visually not by spec-review, screenshot script mocks lag path changes, silent URL "improvements" by backend, apply_patch wrong primitive for creation copy paths, IntegrityError catch beats pre-check SELECT, SendMessage rule applies to all team agents). - tasks/todo.md: status flipped to 🟢 SPRINT COMPLET. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
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(tablesimulation_templates) :idint PKnamestr NOT NULL UNIQUE (limite UX : un template unique par nom pour éviter les doublons dans le dropdown d'instanciation)descriptiontext nullablecommandstext nullable (chaîne multiligne, une commande par ligne, pattern sprint 2)prerequisitestext nullabletechniquesJSON NOT NULL default[](liste[{id, name}], snapshot des techniques MITRE)tactic_idsJSON NOT NULL default[](liste de TA-id strings)created_atdatetime NOT NULLupdated_atdatetime nullablecreated_by_idint FK User
- AC-26.2 : migration Alembic
0005_simulation_templates.py— CREATE TABLE simulation_templates + index surname. 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}], ordrename ASC. SOC → 403. - AC-26.4 :
POST /api/templates(admin|redteam) → 201 + template créé. Body :{name, description?, commands?, prerequisites?, technique_ids?: [...], tactic_ids?: [...]}. Valide :namenon 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_idssprint 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. Sinameest 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.
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/<eid>/simulations(admin|redteam) accepte un nouveau paramètre optionneltemplate_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 parnamedu body si fourni)description← template.descriptioncommands← template.commandsprerequisites← template.prerequisitestechniques← 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 :
POSTsanstemplate_idgarde le comportement sprint 2 (création vierge avec justename). - 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_idstocké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 avectemplate_id+ redirection sur la simu créée. SiuseTemplates()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).
US-28 — En tant qu'admin/redteam, j'accède aux templates depuis la nav
Critères d'acceptation
- AC-28.1 :
Layout.tsxtopbar 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 :
ProtectedRoutepour/admin/templatesimposeroles=["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/templatesn'inclut PAS de mode "read-only SOC" — elle est strictement admin+redteam. Les composants peuvent assumercanEditTemplates = 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)
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 surname. 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):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_simulationmais 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, triname ASC.POST /api/templates(admin|redteam) → création. Validation :namenon vide (400),technique_ids/tactic_idsvalides (réutilise_resolve_technique_ids/_resolve_tactic_idsdesimulation_workflow.py— import direct, KISS). Pour lenameUNIQUE conflict : catchsqlalchemy.exc.IntegrityErrorsur 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 lenameconflict : même pattern, catch IntegrityError sur UPDATE → 409{"error": "template name already exists"}. Cas edge : PATCH avecname == current_name(no-op rename) → 200 (l'UPDATE sur sa propre row ne viole pas UNIQUE). Mettre à jourupdated_at.DELETE /api/templates/<tid>(admin|redteam) → 204. Pas de cascade FK simulations (les simulations n'ont pas detemplate_idFK).
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_idoptionnel. - 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). namedu body override si fourni, sinontemplate.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 deapply_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_patchest ce qui garantit ça structurellement. - Engagement auto-status NE PAS déclencher (status engagement reste planned). Idem —
_maybe_activate_engagementn'est appelé que depuisapply_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 :
0005create/drop round-trip propre (réutilise pattern Alembic round-trip sprint 3/4 avecPath(__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 :
/admin/templatesliste (admin OU redteam vue, ≥ 2 templates) — light + dark- Template create/edit form (mode edit avec techniques + tactic chips) — light + dark
- EngagementDetail avec dropdown "Blank | From template…" ouvert — light
- TemplatePickerModal ouverte (au moins 2 templates listés) — light
- TemplatePickerModal ouverte avec aucun template — empty state visible — light
- 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
SimulationCreateInputavectemplate_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), mutationsuseCreateTemplate,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/newet/admin/templates/:id/edit) — admin+redteam only :- Form pour name + description + commands (textarea) + prerequisites + MitreTechniquesField.
- Mode
new: seulnamerequis ; 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 →useCreateSimulationavectemplate_id: t.id→ redirect sur la simu créée.
- Remplacer le bouton simple "+ New simulation" par un dropdown OU un menu :
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,StatusBadgeetc. - 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)
- Table :
simulation_templatesséparée (clean schema, pas de colonnes nullable confuses). - Instantiation API : extension de
POST /api/engagements/<eid>/simulationsavectemplate_idoptionnel. - Name uniqueness : UNIQUE (1 template par nom, UX dropdown clean).
- 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.
- UI instanciation : dropdown sur le bouton "+ New simulation" (Blank | From template…).
7. Plan d'exécution
- ✅ User a validé les 5 décisions §6 (2026-05-28). SOC zero access acté.
- 🟡 Team-lead met à jour SPEC.md (§0).
- 🟡 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).
- 🔵 Backend-builder : modèle + migration 0005 + endpoints + tests.
- 🔵 Frontend-builder : pages Templates list/form + TemplatePickerModal + nav link + dropdown engagement + tests Vitest. Screenshots mandatory.
- 🔵 Design-reviewer : revoit diff frontend + screenshots.
- 🔵 Code-reviewer : LSP-first review du diff complet.
- 🔵 Test-verifier : e2e US-26 → US-28.
- 🟢 Team-lead :
make open-pr+ récap.
Branche : sprint/5-templates.