Frontend half of the 2026-05-15 amendment (backend shipped in 447f152).
- `MissionScenarioTable` component: per-scenario <table> with 7 cols
(Test | Procédure | Exécution | Source de log | Commentaires | Logs
SIEM | Cyber Incident) + Actions cell. Read mode truncates; double-
click toggles a row into edit mode where each cell becomes the right
control. detection_level lives inside the Commentaires cell as a
pill + select (no 8th column).
- MissionDetailPage Tests tab uses the new component, lifts
`editingTestId` so only one row across the whole mission is editable
at a time. Esc reverts (prompt if dirty), double-click on a different
row with a dirty draft also prompts.
- Full-bleed escape via `calc(50% - 50vw)` (same recipe as the M4 MITRE
picker). 7 dense columns breathe on wide screens, no horizontal scroll.
- `draftDiff(test, draft)` returns `null` when nothing changed → no PUT
on a no-op save. The diff carries only touched fields so the server's
per-field perm gate stays clean.
- Datetime semantics: both datetime-local inputs reuse the M7 verbatim
recipe (`iso.slice(0, 16)` + `${local}:00Z`), zero TZ shift.
Docs
- tasks/testing-m7.md §3.0 documents the column matrix + edit workflow.
- tasks/lessons.md captures the Pydantic ctx-serialisation pitfall, the
naïve-datetime guard, the table-edit pattern.
- CHANGELOG section moves "Frontend (in progress)" → "Frontend (shipped)"
and details the diff.
49 Playwright tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
30 KiB
30 KiB
type, project
| type | project |
|---|---|
| lessons | Metamorph |
Metamorph — Lessons learned
Capture session-level retrospectives here: surprises, traps avoided, decisions revisited. Keep entries short and actionable. Most recent first.
2026-05-08 — M0 bootstrap
- Spec finalisée d'abord (
tasks/spec.md), 8 tours de questions ciblées avant tout code → 0 hypothèse latente avant M0. Pattern à reproduire pour les futurs projets greenfield. - Choix
uvpour le backend Python (rapidité de lock, image Docker plus mince qu'avec poetry). - TLS terminé par reverse proxy externe (cf. spec §6 NF-network) → pas de Caddy/Traefik dans le compose, simplifie le M0.
- Bootstrap du 1er admin via token affiché dans les logs : retenu sur Token-in-logs plutôt que ENV pour éviter de mettre le password en clair dans
.env. - Piège Dockerfile : la process-substitution bash
<(...)ne marche pas dans une instructionRUNDocker car le shell par défaut estsh, pasbash. Soit ajouterSHELL ["/bin/bash", "-c"], soit refactor sans process-sub. Ici j'ai préféré refactor (plus portable) :uv venv+uv pip install --python /opt/venv/bin/python .. Quand unuv.lockexistera, basculer suruv sync --frozen --no-dev. - Vérification d'un compose sans Docker installé :
python3 -c "import yaml; yaml.safe_load(open('docker-compose.yml'))"valide la syntaxe YAML, et un script qui croise lesenvironment:du compose avec.env.exampledétecte les variables manquantes côté docs. - Lancer le subagent
spec-reviewerà chaque fin de milestone (HARD RULE 4 du CLAUDE.md global). J'avais oublié à la fin de M0 ; le user me l'a rappelé. Le reviewer a remonté 6 défauts légitimes en quelques minutes (pre-commit absent, fonts via CDN, secrets par défaut non gardés,make devno-op,database_urldead-code, Node engines non pinned). À automatiser dans le workflow de fin de milestone. - Spec §7 "pas de CDN runtime" s'applique aussi aux fonts, pas seulement aux libs JS. Self-host via
@fontsource/<name>plutôt que Google Fonts<link>— bonus OPSEC (pas de fingerprinting via fonts.googleapis.com). - Pattern de garde de secrets : un
model_validatorPydantic qui refuse de booter enAPP_ENV != "dev"avec des secrets manquants ou égaux aux placeholders de.env.example. Coût quasi nul, élimine la classe entière des "oubli de set en prod". - Makefile portable docker/podman :
ENGINE := $(shell command -v docker … podman …), puis sélection du compose driver en fonction (docker composevspodman composevspodman-composelegacy). Le piège classiqueCOMPOSE ?=ne marche pas si on veut conditionner la valeur par défaut surENGINE— il fautifndef COMPOSE+ifeq ($(ENGINE),docker). Tous les targets restent compose-driven ($(COMPOSE) exec, etc.) ; seulsvolumes/inspect-health/logs-apiont besoin de$(ENGINE)directement, et même là on évite les filtres par label projet (instables entre podman-compose et docker compose) en se reposant surcontainer_name:du compose file.
2026-05-10 — M0 DoD validation (réelle, pas paperware)
- JE DOIS LANCER LE DoD MOI-MÊME avant de déclarer un milestone done. L'utilisateur me l'a fait remonter ; le
make upinitial échouait sur 3 problèmes que la revue statique n'a pas vus. Règle : à chaque fin de milestone, exécuter le DoD localement (make up+ smoke + e2e) en plus du spec-reviewer. - Podman + Fedora exige des FQDN d'image (
docker.io/library/postgres:16-alpine, paspostgres:16-alpine). Le modeshort-name-mode=enforcingfail sans TTY pour prompter. Docker accepte le même préfixe transparente. → Dorénavant tous lesimage:etFROM …des projets cross-engine sont qualifiés. .dockerignorequi exclut*.mdcassepyproject.tomlqui référencereadme = "README.md": hatchling lit le README au build pour valider les métadonnées. Soit on copie le README explicitement, soit on n'exclut pas les*.md, soit on retire la cléreadme. J'ai retiré la clé pour découpler.extends HTMLAttributes<HTMLDivElement>clash surtitle: la prop native eststring, donc redéfinirtitle?: ReactNodeproduit TS2430. Pattern à retenir :Omit<HTMLAttributes<…>, 'title'>quand on overloadtitle/color/autoFocusetc.- Podman-compose 1.x ne surfait pas les
HEALTHCHECKdu Dockerfile danspodman inspect: il faut redéclarer le healthcheck dans ledocker-compose.ymlpour quemake inspect-healthvoie réellement l'état. Bonus : c'est aussi plus portable. - Piège shell :
make up 2>&1 | tail -80bloque quand la sortie est petite, parce quetailbufferise jusqu'à recevoir SIGPIPE en fin de pipeline ; quand le build est lent, on n'a aucune sortie pendant des minutes. Fix : rediriger vers fichier (>/tmp/log 2>&1) puistailséparément, ou utiliser leMonitortool pour streamer. PODMAN_COMPOSE_WARNING_LOGS=falsemasque le banner "Executing external compose provider …" qui spamme chaque commande. À exporter depuis le Makefile.
2026-05-10 — M1 schéma DB & migrations
- Compose pioche le DERNIER stage du Dockerfile par défaut. En ajoutant un stage
testaprèsruntime, le containerapis'est mis à exécuterpython -m pytestau lieu degunicorn, en boucle (exit 1 → restart → exit 1). Fix :target: runtimeexplicite dansdocker-compose.yml. Règle : toujours précisertarget:quand un Dockerfile a >1 stage final viable. - Snapshot vs référence (spec §11) : pour qu'un snapshot survive à un re-sync de la référence (ex : MITRE qui retire une technique), il faut dénormaliser les champs descriptifs dans la table snapshot (ici
mitre_external_id,mitre_name,mitre_url) et ne pas mettre de FK vers la table source. Si on garde une FK, la cascade détruit la donnée historique (CASCADE) ou bloque le sync (RESTRICT). La dénormalisation est le bon trade-off pour un état figé en lecture après archivage. SoftDeleteMixin.__table_args__est silencieusement écrasé par la classe enfant qui déclare son propre__table_args__. Pattern à éviter pour les mixins qui veulent ajouter des contraintes/index. Soit ne rien mettre dans__table_args__du mixin (et imposer aux classes de déclarer l'index), soit utiliserevent.listens_for("after_parent_attach", ...). J'ai choisi la 1re option : explicite > magique.- Workflow Alembic en container :
alembic revision --autogeneratecrée le fichier dans le container, qu'il fautpodman cpvers l'host avant rebuild. Sinon perdu. Ajouter ce détail dans la doc M1 (et envisager un bind mountdevplus tard). - Bypass
APP_ENVdoit couvrirdevETtest: un container test légitime ne doit pas avoir besoin de secrets prod-grade.if self.APP_ENV in ("dev", "test"): return self. pytestdans le runtime image, c'est non. Faire un stagetestdédié (multi-stage--target test) qui étenddeps+dev extras+tests/, lancé viapodman run --rm --network <project>_<network>en éphémère. Le runtime reste minimal en prod.- Le test d'intégration "expected tables/FK/CHECK" est le bon filet de sécurité pour M1+ : il a immédiatement attrapé les fixes du reviewer (le retrait de
ck_mission_test_mitre_tags_exactly_one_mitre_fkaurait été un oubli silencieux sinon). - Lancer le DoD avant de dire "M1 done" : règle gravée à M0, respectée ici.
make clean && make up && make migrate && make test-api && make e2eest la séquence canonique de fin de milestone.
2026-05-12 — M4 MITRE ATT&CK
- STIX parsing avec stdlib uniquement (
urllib.request+json+hashlib) suffit pour 50 MB de bundle, ~1.1 s parse end-to-end. Pas besoin derequests/httpx. Toute future ingestion de gros JSON pinné → stdlib first, ne pas inflater l'image pour un cas d'usage one-shot. - Le sous-jacent MITRE évolue : la spec mentionne "14 tactics" mais la v19 actuelle en ship 15 (Reconnaissance + Resource Development depuis v8). Les assertions de DoD sont à exprimer en
>= Xquand un référentiel externe est en jeu, pas en== X. Pattern : décorréler la valeur exacte du contrat (sinon la maintenance casse au prochain bump). - Sub-technique parent resolution : la source authoritative est la
relationship[subtechnique-of]STIX, pas la convention dotted-idT1003.001 → T1003. La regex en fallback ne sert que si la relation manque (jamais le cas avec MITRE officiel, mais utile pour bundles custom). session_scope()enveloppe tout le seed dans une seule transaction → les lecteurs externes ne voient jamais un état intermédiaire pendant le DELETE+INSERT demitre_technique_tactics. Postgres READ COMMITTED isole. Pas besoin d'advisory lock sauf si on s'attend à des syncs concurrents.- Checksum bypass silencieux = footgun. Avant :
source != MITRE_DEFAULT_URL→expected_sha256 = None. Un admin qui type un domaine attaquant dansmitre_source_urlingère du JSON arbitraire sans intégrité. Patron correct :MitreSeedError("custom URL requires an expected_sha256 or allow_unverified=True"). L'opt-out explicite (--skip-checksumcôté CLI,allow_unverified: truecôté API) reste possible mais visible. /diag/resetcohérence : si on TRUNCATEsettingsmais pas les tables de référence MITRE (gardées car coûteuses à re-seeder),GET /mitre/statusretournelast_sync: nullalors queGET /mitre/tacticsretourne 15 lignes. Discrepancy mensongère. Fix : TRUNCATE aussi lesmitre_*dans/diag/reset(test-only endpoint, on accepte la re-sync via/mitre/syncenbeforeAll).- Volume permissions et chown au build :
mkdir -p /data/mitre && chown -R metamorph:metamorph /datadans le Dockerfile suffit POUR le premiermake up(podman copie l'ownership de l'image lors de l'init du named volume). Mais si un volume préexiste owned root, le chown ne replay pas. À documenter en pré-requis danstasks/testing-m<N>.md, ou ajouter un entrypoint shim qui valide les perms au boot. - Build cache du front silencieux :
podman buildmontreUsing cache aad724...même quand src/ a changé si le diff entre les arborescences est invisible (mtime). En cas de doute :podman build --no-cacheune fois pour confirmer que le typecheck passe, puismake down && make uppour pousser le bundle. Réflexe à garder en mémoire.
2026-05-11 — M3 RBAC, groupes, users, invitations
logging.LogRecordréservenamecomme attribut interne (en plus demessage,levelname,pathname,filename,module,funcName,lineno,asctime,process,thread,args). Donclog.info("metamorph.x.created", extra={"name": entity.name})lèveKeyError: "Attempt to overwrite 'name' in LogRecord". Patron : préfixer toute clé risquée par l'entité (group_name,user_name,template_name). À documenter dans le style guide quand on en aura un.- Pattern "sentinel pour distinguer absent vs null" : Pydantic ne sait pas distinguer
{}de{"display_name": null}quand le champ eststr | None = None. Solution : lireraw = request.get_json()puis tester"display_name" in rawdans la couche API, passer un sentinel...au service, qui distingue "ne pas toucher" de "set à None". Lourd mais explicite. Si ça revient souvent, encapsuler dans un helpertriState(raw, key, payload). limiter.reset()flask-limiter est public et clean — pas besoin de toucher àlimiter._storage. À appeler dans/diag/resetquand le limiter estenabled. Toujours guarder avecif limiter.enabledpour ne pas planter enAPP_ENV=test.- Rate-limit scope
APP_ENV in ("prod", "staging"): meilleure granularité que prod-only. La spec NF-security est operator-facing, pas dev. Trade-off réconcilié dansapp/core/rate_limit.pyavec un docstring explicite. Dev = ergonomics totale, prod/staging = limiter actif, test = désactivé. - Playwright
workers: 1+fullyParallel: falsequand chaque spec file fait du/diag/reset(DB partagée). Avec parallélisme, les workers se truncate mutuellement entre eux → install token consumé, etc. Pattern simple et robuste : un seul worker pour les e2e, parallélisme intra-file laissé àtest.describe.configure({ mode: 'serial' }). - Sessions Playwright entre tests : chaque
test()reçoit unepageneuve (BrowserContext fresh). Pas de partage de session entre tests du mêmedescribe. HelperloginViaSpa()à appeler au début de chaque test SPA-driven (les tests purement API peuvent partager via une variable de spec mais c'est rare). Alternative :storageStateglobal, mais ça complique le truncate workflow. - Dual seed = boot + bootstrap : seeder les perms au boot ET dans
bootstrap_admin()n'est pas redondant. Sur DB fraîchement migrée vide, le boot suffit. Mais après/diag/reset(qui TRUNCATEpermissions+group_permissions+groups), seul/setupre-déclenche le chemin de seed viabootstrap_admin → seed_all. Sans ce 2e appel, l'admin créé auraitis_admin=Truemais le catalogue serait vide. - Snapshot UserView/GroupView détachés : retourner des
@dataclass(frozen=True)au lieu de l'ORM permet de fermer lesession_scopeimmédiatement. Plus simple ques.expunge()pour chaque champ, et la couche API peut sérialiser sans lazy-loading. Patron à reproduire pour tous les services. - Invariant "admin a toutes les perms" : même si le décorateur bypass via
is_admin = "admin" in group_names(et pas via le perm set), garder l'invariant côté API en refusantset_group_permissions(admin_group, !=all_codes). Future-proof : si on bouge le bypass à un check perm-based plus tard, l'invariant tient déjà.SystemGroupProtectedréutilisé pour le 409. - Toujours rebuild front + recreate containers :
make rebuildne recrée pas les containers, donc le bundle nginx reste l'ancien. Patron canonique :make down && make up. Documenté pour la 2e fois dans M3 ; à faire passer en runbook au prochaintasks/testing-m<N>.md.
2026-05-10 — M2 auth, JWT, invitations
pydantic.EmailStrrejette les TLD réservés (.local,.corp,.test, …) viaemail-validatorglobally_deliverable=True. Pour un outil red-team utilisé en lab/intranet, créer un type custom permissif (Annotated[str, AfterValidator(...)]) avec une regex RFC-shape. À garder en tête pour tout futur projet "internal".- Cookies
Secure=TruesurlocalhostHTTP : modern browsers (Chrome ≥89, Firefox ≥75) traitentlocalhostcomme un secure context et acceptent les cookiesSecuremême servis en HTTP. Donc on peut respecter la spec strictement (Securetoujours) sans casser le dev — pas besoin de gating parAPP_ENV. getByLabelde Playwright prend le nom accessible de l'input. Quand un<label>enveloppeinput+<span>hint +<span>error, le hint et l'error polluent le nom etgetByLabel('Password', exact: true)ne matche plus. Pattern correct :<div>parent,<label htmlFor>séparé du<input id>, hint et error en<p>siblings hors du<label>.flask-limiterdoit être désactivé enAPP_ENV=testsinon les tests qui font 10+ logins de suite rate-limit.Limiter(..., enabled=settings.APP_ENV != "test")règle le cas globalement.pydantic[email]extra est REQUIS dès qu'on utiliseEmailStr. Ne pas s'en rendre compte donne un crash gunicorn worker au boot avecImportError: email-validator is not installed. À dupliquer dans le starter pyproject pour les futurs projets.- Compose
target:est OBLIGATOIRE quand un Dockerfile a un stage après le runtime — par défaut compose builde le DERNIER stage. J'ai été mordu deux fois (M1 puis M2). Désormais : tout Dockerfile multi-stage avec un stage de test/dev →target: runtimeexplicite dansdocker-compose.yml. - Refresh token rotation + chain revoke : à chaque
/auth/refresh, on marque l'ancien tokenrevoked_at+replaced_by_id. Si quelqu'un re-présente un token déjà rotaté, on cascade-revoke toute la chaîne (compromise probable). Pattern à reproduire pour tout système JWT à long terme. make rebuildne recrée pas les containers — il fautmake down && make upaprès un changement front pour que nginx serve le nouveau bundle. Important quand on debug un test e2e qui attend un selecteur récemment ajouté côté React.podman compose stop apipuisup -d apicasse les dépendances entre containers (dbhealthy →apidepends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vautmake down && APP_ENV=test make up./diag/resettest-only : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé endevETtest(pas en prod), avec un logWARNINGà chaque appel. Si jamais on déploie une stack dev publique, désactiver l'endpoint via env var.
2026-05-15 — M7 amendement : 5 champs blue + vue tabulaire
e.errors()de Pydantic v2 embarque l'exception originale dansctxquand unAfterValidatorlève.jsonify(e.errors())crash avecTypeError: Object of type ValueError is not JSON serializable. Fix project-wide :e.errors(include_context=False, include_url=False)— strippe le ctx et l'URL doc, garde le reste qui est déjà JSON-safe. Sed global surbackend/app/api/*.py. Mémo : si la stack ajoute un nouveau handlerexcept ValidationError as e:, prendre le même pattern.- Pydantic
EmailStrreste trop strict pour le projet (lessons M2 captured this). Pour le destinataire d'alerte, j'ai utiliséAnnotated[str, AfterValidator(_validate_email_shape)]avec une regex^[^@\s]+@[^@\s]+\.[^@\s]+$. Pas de validation TLD. Si un futur champ "production" exige de la rigueur, on aura besoin de deux types :_InternalEmail(permissive) et_PublicEmail(strict). Pas le cas aujourd'hui — outil interne. - Naïve datetime +
timestamptz= piège silencieux. Postgres interprète la datetime naïve dans la session TZ ; sur l'API la majorité des clients enverra2026-05-15T11:00:00sansZ. Réponse :Annotated[datetime, AfterValidator(_ensure_aware_datetime)]qui rejettetzinfo is Noneavec 400. Le front respecte déjà la convention (append:00Zau datetime-local). À reproduire sur tout nouveau champtimestamptzaccepté en write. - Élargir
MissionTestView(la vue nested dansGET /missions/{id}) est OK tant qu'on garde la requête en O(1) — j'ai ajouté ~15 fields mais batch-load les détections + last-actor users en 2 queries totales, peu importe le nombre de tests. Sans le batch, c'était un classique N+1. - Pattern édition inline en table :
- State
editingTestIdau-dessus du tableau (un seul row en édition à la fois sur toute la mission). draftlocalement àMissionScenarioTable, copié depuis le test à l'entrée d'édition.draftDiff(test, draft)retournenullsi rien n'a changé (évite unPUTvide).useEffect([editingTestId])re-derive le draft seulement quand l'identité change (pas sur polling refetch — leçon M7 déjà capturée).window.confirmsur Esc-with-dirty et sur double-click d'une autre ligne avec dirty draft.
- State
- Full-bleed escape
max-w-page:marginLeft: 'calc(50% - 50vw)'+marginRight: 'calc(50% - 50vw)'+width: '100vw'. Pattern déjà inventé pour le picker MITRE en M4 ; testé OK pour 7 colonnes denses. À factoriser dans un composant<FullBleed>si on en a un 3ᵉ usage. detection_levelrendu en pill dans la cellule Commentaires plutôt que comme 8ᵉ colonne : la spec listait 7 colonnes héritées d'Excel ; ajouter une 8ᵉ aurait cassé le mental model du user. Pill au-dessus du commentaire est plus naturel + économise l'espace horizontal.
2026-05-14 — M7 execution + evidence + activity
logging.LogRecordreservescreated— same trap asname(M3 lessons):extra={"created": n}raisesKeyError: "Attempt to overwrite 'created' in LogRecord". Pattern: prefix with the entity (rows_created). Thecreatedis the LogRecord timestamp, hence the conflict. Reserved-key cheatsheet (kept growing):name, msg, args, levelname, levelno, pathname, filename, module, funcName, created, msecs, lineno, thread, threadName, process.- Query string
+is%20oncerequest.argsdecodes it. A naked ISO datetime in?since=2026-05-14T07:55:16+00:00arrives as2026-05-14T07:55:16 00:00, whichdatetime.fromisoformatrejects withValueError. The fix is on the client (URL-encode) — not on the server (a tolerant "space → +" reparse would conflate real-spaces with un-encoded plusses). Now codified intesting-m7.md§7 + every test that hits/activity?since=callsurllib.parse.quote. - Field-level perm enforcement must happen before the SQL transaction. First M7 draft did
_load_test(...)thenif not allowed: raise. Two issues: (a) extra DB hit on a refused request, (b) audit log conflated "row exists" with "perm denied". Refactor: classify the touched fields → check perms → only then entersession_scope. Cleaner audit log and one fewer round-trip on the 403 path. - Streamed upload + atomic move is the canonical pattern for content-addressed evidence. Writing chunks to a tmpfile inside the final per-test dir lets
shutil.movereduce to a POSIXrename(2)(atomic). If the SHA256 already exists on disk (re-upload of the same bytes), we drop the tmp and reuse — a fresh DB row records who uploaded it, even though no new bytes hit the disk. Saves storage AND preserves provenance. - Pyright's "underscore prefix unused" rule does not silence destructured tuple slots.
ev, _test, scenario = chainstill triggers"_test is not accessed". Workaround: use a single underscore (_) or index the tuple. Single underscore is conventional in Python for "I'm intentionally ignoring this". - TanStack v5
useQueryClient.setQueryData(detailKey, next)is the right idiom after a mutation that returns the freshly-saved row — avoids a refetch, and the polling query still invalidates correctly on activity events. Pattern:onSuccess: (next) => { qc.setQueryData(detailKey, next); qc.invalidateQueries({ queryKey: parentKey }); }. - Activity polling must gate on
document.visibilityState === 'visible'or every backgrounded tab hits the API every 15 s, multiplying for free across a team's tab graveyard. Single-line check; massive impact. - PUT vs transition split kept the model coherent. Tempting to fold "mark executed" into PUT
{state:'executed'}but it conflates two concerns: state lifecycle vs field write. Keeping the transition POST separate makes the side-effect (executed_at = now()) easy to reason about and the perm gate per-target trivial. /diag/resetmust clean the evidence dir in test mode otherwise the e2e suite accumulates 24 MB blobs across runs. Gated onAPP_ENV == "test"sodevkeeps the operator's manual uploads.- The
last_actor_idmigration adds an index onupdated_at— without it, the activity poll'sWHERE updated_at > since ORDER BY updated_at DESCwas sequential-scanning. With the index, the plan switches to an index range scan even on the empty case (which is the most common one when nothing has changed).
2026-05-13 — M6 missions + snapshot
- Snapshot independence requires more than column copies — denormalise the join tables too.
mission_testscopies every scalar template field, but ifmission_test_mitre_tagskept FKs tomitre_*rows, a future re-sync that drops a technique would cascade throughON DELETE CASCADEand silently mutate frozen missions. The M1 schema already splitmission_test_mitre_tagswith frozen(mitre_external_id, mitre_name, mitre_url)columns and no FK — at snapshot time we denormalise via a 3-query batch lookup (_resolve_mitre_lookup) and build the rows in-memory. Pattern to reuse for any "frozen reference" relationship in the future. /diag/resettruncate order is FK-aware:mission_scenarios.source_scenario_template_idandmission_tests.source_test_template_idareON DELETE SET NULL. Truncating template tables first would force PG to NULL those columns one by one. Reverse the order — wipe mission tables (which cascade to members/scenarios/tests/tags/categories frommissions) BEFORE the templates. Saves a round-trip + keeps the truncate logically aligned with the dependency graph.- Membership visibility = 404, not 403. Returning 403 for "mission exists but you're not a member" leaks the existence of the mission. The service returns 404 in both "doesn't exist" and "not visible to you" cases via the same
MissionNotFoundexception. The decorator stack handles perm-level 403 (you can't even GET /missions); the service handles row-level 404. Pattern: gate "type of action" via decorator perms, gate "which rows" via service-level membership filters that collapse to 404. - Auto-add the non-admin creator as a member. Without this, a redteamer who creates a mission and forgets to add themselves to
members[]immediately loses visibility (403 on subsequent GETs because they're not a member). Solved at the service layer:if not creator_is_admin and creator_id not in members: prepend (creator_id, 'red'). Admin creators don't auto-add because they bypass membership anyway. Documented in the docstring + tested explicitly (test_non_admin_creator_auto_added). - Minimum-surface roster endpoint pattern:
/usersreturns admin metadata (is_admin via groups, is_active, group memberships). The mission wizard needs a list of assignable users from a non-admin redteamer's perspective — exposing /users to them would leak admin metadata. Added a dedicatedGET /users/rosterreturning only(id, email, display_name)and gated by any ofuser.read,mission.create,mission.update. Pattern: when a cross-feature needs a smaller slice of an admin endpoint, create a dedicated lightweight endpoint rather than relaxing the admin one. - Pyright is not always wrong about "unused" parameters — the original
_to_list_item(s: Session, m: Mission)tooksbut never accessed it (the function uses already-selectinloaded relationships). Removed the param. Lesson: when adding aSessionparameter to a view-assembly helper, audit whether the body actually issues queries through it. flask.abort()is not typedNoReturnin this project's Pyright config sodef f() -> X: if x is None: abort(...); return xraises a return-type error. Workaround: addassert user is not Noneafter the abort to narrow the type. Cleaner thancast(...). Pattern to reuse anywhere we abort-and-return.- Snapshot of multiple scenarios is a 4-query write regardless of test count: (1) load N scenario_templates with their join rows, (2) load M test_templates by id with mitre_tags, (3) batch-resolve MITRE rows (3 queries for tactic/technique/sub), (4) insert mission_scenarios + mission_tests + mission_test_mitre_tags via the SQLAlchemy unit of work. Avoid the temptation to query inside per-test loops — it explodes to O(scenarios × tests × tag_kinds) easily.
2026-05-12 — M5 templates + scenarios
extra={"name": ...}danslog.info()crash silencieusement — Python'slogging.LogRecordréservename(le logger name). Coût : 500 sur le POST, message peu parlant (KeyError: "Attempt to overwrite 'name' in LogRecord"). Fix : renommer la clé (template_name). Liste réservée à éviter :name,msg,args,levelname,levelno,pathname,filename,module,funcName,created,msecs,lineno,thread,threadName,process. Pattern : préfixer les clés extra par l'entité (template_name,group_id,user_idest OK maisidaussi est piégeux dans certains setups).- React 18 +
setX((prev) => ({...prev, val: e.currentTarget.value }))→ page blanche au 1er input.e.currentTargetest cleared après la fin du bubble, AVANT que l'updater fonctionnel exécute. Le synthetic event survit (pas de pooling depuis React 17), maiscurrentTargetest setté/cleared par le dispatcher. Fix :e.target.value(qui persiste sur le synthetic event), ou capturerconst v = e.currentTarget.value;avant lesetX. À garder en tête : toutonChangequi passe par un updater fonctionnel doit liree.target, pase.currentTarget. - Sentinel
Any = object()plutôt que... (Ellipsis)pour les "field unset" optional en service Python. Pyright voit... = object()correctement commeAny, alors quedescription: str | None | object = ...renddescription.strip()invalide. Pattern :_UNSET: Any = object()au top du module +description: Any = _UNSETdans la signature +if description is not _UNSET: .... Net + typecheck-friendly. - Postgres UNIQUE(scenario_id, position) + position-swap = ON CONFLICT pendant l'UPDATE. Pour réordonner, le pattern naïf (UPDATE position) viole la contrainte sur le 1er swap. Trois options : (a) full delete + re-insert dans la même tx [retenu, atomique + lisible], (b) shift d'offset (UPDATE position = position + 1000 puis renumérotation), (c) deferred constraint. (a) gagne en simplicité — la liste rarement >50 éléments, le coût est négligeable.
@dnd-kit/sortablerequiresuseSortable({ id })IDs to be unique and stable across renders. Si on utilise un index numérique comme id, drag-and-drop ne réagit pas. Utilisertest_template_id(UUID stable) marche directement.- Frontend deps ajoutés à
package.jsonsanspackage-lock.json: le Dockerfile faitnpm install --no-audit --no-fundsur fallback. OK pour M5 (3 deps@dnd-kit/*). À l'avenir, freeze un lockfile avant M14 pour build reproductibles. - Playwright
getByTestIdest défini partestIdAttributeName: 'data-testid'dansplaywright.config.ts. Pour qu'un test-id descende sur l'input via TextField, il faut que...restsoit spread sur l'input (déjà OK dansTextField.tsx). Mais avec un wrapper<div><label/><input/></div>,getByTestIdmatche le DIV si le test-id est dessus. Bien le mettre sur l'élément interactif (input/button), pas sur le container. /diag/resettruncate order matters :scenario_template_tests.test_template_idest FKON DELETE RESTRICT, donc il faut truncatescenario_template_testsAVANTtest_templates. Hierarchy :scenario_template_tests → scenario_templates → test_template_mitre_tags → test_templates → mitre_*. Maintenant inscrite dansdiag.py.- Modal embarquant le
MitreTagPickercomplet (15 cols × 50 techniques) : le picker se charge via/mitre/matrix(~94 KB). Affichage instantané, OK. Pour de futurs modals lourds, considérer le lazy-render derrière un toggle ou tab.