Milestone 3

This commit is contained in:
Knacky
2026-05-11 06:05:27 +02:00
commit 4c25e198fc
125 changed files with 13489 additions and 0 deletions

376
tasks/design.md Normal file
View File

@@ -0,0 +1,376 @@
# Design System — Red Team Operations Map
Reusable design spec extracted from `kypvas.github.io/red-team-map/`. Dark "operator briefing / terminal" aesthetic: information-dense, color-coded taxonomy, monospace-first, zero ornament.
---
## 1. Philosophy
- **Dark, flat, terminal-inspired.** No gradients, no drop shadows, no glows. Depth comes from 1px borders on slightly lighter card backgrounds.
- **Information over decoration.** Every visual element serves data density — cards, tags, colored borders, inline code.
- **Color as taxonomy.** 10 accent hues are not decoration — each one *means* a category (red = evasion/payload, cyan = lateral, purple = C2, etc.). Reuse hues consistently across projects so color carries meaning.
- **Monospace as identity.** `JetBrains Mono` for everything structural (titles, labels, code, tags). `IBM Plex Sans` only for prose body.
- **Comment-style section markers.** Headings begin with `//` — carries the "source code / operator notes" metaphor.
---
## 2. Color Tokens
All colors are declared as CSS custom properties on `:root`. Copy-paste verbatim:
```css
:root {
/* Surfaces */
--bg: #0a0e1a; /* page background — deep navy-black */
--bg-card: #111827; /* card / panel background */
--border: #1e2d3d; /* default 1px border, separators */
/* Text */
--text: #94a3b8; /* default body copy (slate) */
--text-bright: #f8fafc; /* titles, emphasis */
--text-dim: #64748b; /* metadata, subtitles, arrow labels */
/* #475569 used inline for code comments */
/* Accent palette — each one maps to a category */
--red: #ef4444; /* evasion, payload, privesc, danger */
--orange: #f59e0b; /* access, credentials, AD, MOTW */
--yellow: #eab308; /* exfil */
--green: #10b981; /* phishing, social */
--cyan: #06b6d4; /* lateral movement, default code highlight */
--blue: #3b82f6; /* infrastructure, cloud */
--purple: #8b5cf6; /* C2, macOS, tooling */
--pink: #ec4899; /* injection */
--rose: #f43f5e; /* OPSEC, vishing */
--teal: #14b8a6; /* persistence, linux */
}
```
### Usage pattern — tinted fills
Never use accent color as a solid background. Always as `rgba(accent, 0.100.15)` behind solid-colored text:
```css
.tag.c2 { background: rgba(139, 92, 246, 0.15); color: var(--purple); }
.tag.cred { background: rgba(245, 158, 11, 0.15); color: var(--orange); }
code { background: rgba(6, 182, 212, 0.08); color: var(--cyan); }
code.vuln { background: rgba(239, 68, 68, 0.10); color: var(--red); }
code.tool { background: rgba(139, 92, 246, 0.10); color: var(--purple); }
```
---
## 3. Typography
### Font stack
```html
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
```
- **`JetBrains Mono`** — headings, labels, code, tags, navigation, anything structural.
- **`IBM Plex Sans`** — prose body only (`<p>`, card descriptions).
- Weights used: Mono `300 / 400 / 500 / 600 / 700`, Plex Sans `300 / 400 / 500 / 600`.
### Scale
| Role | Family | Size | Weight | Extras |
|-----------------|-----------------|------|--------|---------------------------------|
| Page title (h1) | JetBrains Mono | 28px | 700 | `letter-spacing: -0.5px` |
| Subtitle | JetBrains Mono | 14px | 300 | color `--text-dim` |
| Section (h2) | JetBrains Mono | 18px | 600 | `border-bottom: 1px var(--border)`, `padding-bottom: 12px` |
| Card title (h3) | JetBrains Mono | 14px | 600 | color `--text-bright` |
| Card sub-label | JetBrains Mono | 10px | 400 | `letter-spacing: 0.5px`, `--text-dim` |
| Section desc | JetBrains Mono | 12px | 400 | `--text-dim` |
| Body copy | IBM Plex Sans | 12px | 400 | `line-height: 1.7` |
| Flow node | JetBrains Mono | 10px | 400 | |
| Arrow label | JetBrains Mono | 8px | 400 | `--text-dim` |
| Tag / pill | JetBrains Mono | 9px | 600 | `text-transform: uppercase; letter-spacing: 1px` |
| Inline code | JetBrains Mono | 10px | 400 | |
| `<pre>` block | JetBrains Mono | 11px | 400 | `line-height: 1.7` |
| Footer | JetBrains Mono | 11px | 400 | `--text-dim` |
### Global body
```css
body {
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Sans', sans-serif;
padding: 40px 60px;
line-height: 1.6;
}
```
---
## 4. Layout & Spacing
- **Container**: `max-width: 1400px; margin: 0 auto;`
- **Page padding**: `40px 60px` (desktop-first, no mobile breakpoints in the source)
- **Grid** for card collections:
```css
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
```
- **Section rhythm**: `margin-top: 60px; margin-bottom: 30px` on section headers.
- **Separators**: thin hairlines only — `border-top: 1px solid var(--border); margin: 40px 0`.
- **Line-height**: 1.6 globally, 1.7 inside cards and `<pre>` blocks for dense technical content.
---
## 5. Components
### 5.1 Header / Hero
```html
<header>
<h1>Red Team <span>Operations</span> <span class="acc2">Architecture</span> Map v1.1</h1>
<div class="subtitle">Comprehensive Operator Reference — From Infrastructure to Impact</div>
</header>
```
```css
header { text-align: center; margin-bottom: 50px; }
header h1 { font: 700 28px 'JetBrains Mono'; color: var(--text-bright); letter-spacing: -0.5px; margin-bottom: 8px; }
header h1 span { color: var(--red); }
header h1 .acc2 { color: var(--purple); }
header .subtitle { font: 300 14px 'JetBrains Mono'; color: var(--text-dim); }
```
> **Pattern**: white title with two coloured accent words (red + purple). Reuse `<span>` to highlight 12 keywords only.
### 5.2 Section heading
```html
<div class="section-header">
<h2><span>//</span> Operation <span class="red">Flow Chains</span></h2>
<p class="section-desc">End-to-end attack chains ...</p>
</div>
```
```css
.section-header { margin-top: 60px; margin-bottom: 30px; }
.section-header h2 { font: 600 18px 'JetBrains Mono'; color: var(--text-bright);
padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.section-header h2 span { color: var(--cyan); } /* the "//" marker */
.section-header h2 .red { color: var(--red); } /* + .green / .orange / .purple / .pink / .teal / .yellow / .blue */
.section-desc { font: 12px 'JetBrains Mono'; color: var(--text-dim); margin-top: 8px; }
```
> **Signature move**: every h2 starts with a cyan `//` followed by a plain word and one colored word — source-code comment vibe.
### 5.3 Detail card
```css
.detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
}
.detail-card h3 { font: 600 14px 'JetBrains Mono'; color: var(--text-bright); margin-bottom: 4px; }
.detail-card .card-sub { font: 10px 'JetBrains Mono'; color: var(--text-dim); margin-bottom: 12px; letter-spacing: 0.5px; }
.detail-card .card-body { font-size: 12px; line-height: 1.7; }
/* accent-border variants */
.border-red { border-color: var(--red) !important; }
.border-cyan { border-color: var(--cyan) !important; }
/* ... one class per accent */
```
> Cards share identical chrome; they are **distinguished solely by border color**. That single accent ties card → category → tag → flow-node without repeating the hue anywhere else.
### 5.4 Tag / pill
```css
.tag {
font: 600 9px 'JetBrains Mono';
text-transform: uppercase;
letter-spacing: 1px;
padding: 3px 8px;
border-radius: 4px;
display: inline-block;
margin-bottom: 8px;
margin-right: 4px;
}
.tag.c2 { background: rgba(139, 92, 246, 0.15); color: var(--purple); }
.tag.evasion { background: rgba(239, 68, 68, 0.15); color: var(--red); }
.tag.lateral { background: rgba(6, 182, 212, 0.15); color: var(--cyan); }
/* ... one class per category */
```
### 5.5 Flow node + arrow
Nodes chain horizontally in a `flex` row with thin SVG arrows between them.
```css
.flow-block { margin-bottom: 18px; }
.flow-title { font: 600 12px 'JetBrains Mono'; margin-bottom: 10px; }
.flow-title.red { color: var(--red); } /* one per accent */
.flow-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 10px 0; }
.flow-node {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font: 10px 'JetBrains Mono';
color: var(--text);
white-space: nowrap;
flex-shrink: 0;
}
.flow-node.hl-red { border-color: var(--red); color: var(--red); }
.flow-node.hl-cyan { border-color: var(--cyan); color: var(--cyan); }
/* ... one per accent */
.flow-arrow { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
.flow-arrow svg { width: 36px; height: 20px; }
.flow-arrow .arrow-label { font: 8px 'JetBrains Mono'; color: var(--text-dim); margin-top: -2px; }
```
Arrow SVG template (inline, stroke colour = destination-node accent):
```html
<svg viewBox="0 0 36 20">
<line x1="0" y1="10" x2="31" y2="10"
stroke="#10b981" stroke-width="1.5"
marker-end="url(#arrowG)"/>
</svg>
```
### 5.6 Data-flow / code card
```css
.data-flow-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
margin-bottom: 20px;
}
.data-flow-card h4 { font: 600 13px 'JetBrains Mono'; color: var(--text-bright); margin-bottom: 12px; }
.data-flow-card pre { font: 11px 'JetBrains Mono'; line-height: 1.7; color: var(--text-dim); overflow-x: auto; }
.data-flow-card pre .key { color: var(--cyan); font-weight: 600; }
.data-flow-card pre .val { color: var(--text-bright); }
.data-flow-card pre .type { color: var(--blue); }
.data-flow-card pre .comment { color: #475569; font-style: italic; }
.data-flow-card pre .danger { color: var(--red); font-weight: 600; }
```
> Pseudo-syntax-highlighting via `<span class="key|val|type|comment|danger">` inside `<pre>` blocks — mimics an IDE theme without a real parser.
### 5.7 Inline code
```css
code { font: 10px 'JetBrains Mono'; padding: 2px 6px; border-radius: 3px;
background: rgba(6, 182, 212, 0.08); color: var(--cyan); }
code.vuln { background: rgba(239, 68, 68, 0.10); color: var(--red); }
code.tool { background: rgba(139, 92, 246, 0.10); color: var(--purple); }
```
### 5.8 List inside card
```css
.card-list { list-style: none; padding: 0; }
.card-list li { padding: 3px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.03); }
```
> Near-invisible divider (`rgba(255,255,255,0.03)`) — rhythm without visual noise.
### 5.9 Footer
```css
footer {
text-align: center;
margin-top: 60px;
padding: 30px 0;
border-top: 1px solid var(--border);
font: 11px 'JetBrains Mono';
color: var(--text-dim);
}
```
---
## 6. Borders, Radii, Elevation
| Element | Radius | Border |
|----------------|--------|------------------------------|
| Detail card | 10px | 1px solid var(--border) or accent |
| Data-flow card | 10px | 1px solid var(--border) |
| Flow node | 6px | 1px solid var(--border) or accent |
| Tag | 4px | none |
| Inline code | 3px | none |
- **No `box-shadow` anywhere.**
- **No gradients.** Surfaces are flat hex fills.
- **Depth cue** = border on a `#111827` panel over a `#0a0e1a` background. That's the whole elevation system.
---
## 7. Motion
The stylesheet defines **no transitions, no hovers, no animations**. Static document. If you add motion in derivative work, keep it restrained: ~120 ms fades on border-color at most. Don't introduce scale, shadow, or glow effects — they'd break the briefing aesthetic.
---
## 8. Iconography
No icon font, no Lucide/Heroicons. All pictograms are **inline SVG arrows** with `stroke-width: 1.5` and `<marker-end>` arrowheads, one per accent color. Tags replace icons: `[C2]`, `[EVASION]`, `[LATERAL]` carry the same recognition load.
If icons are ever added, use a thin (1.5px) monochrome line set (e.g. Lucide `strokeWidth={1.5}`) and color them with accent vars.
---
## 9. Reusable Starter Template
Drop-in `<head>` + body baseline for a new project in this style:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Name</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e1a; --bg-card: #111827; --border: #1e2d3d;
--text: #94a3b8; --text-bright: #f8fafc; --text-dim: #64748b;
--red:#ef4444; --orange:#f59e0b; --yellow:#eab308; --green:#10b981;
--cyan:#06b6d4; --blue:#3b82f6; --purple:#8b5cf6; --pink:#ec4899;
--rose:#f43f5e; --teal:#14b8a6;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: 'IBM Plex Sans', sans-serif; padding: 40px 60px; line-height: 1.6; }
.container { max-width: 1400px; margin: 0 auto; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>Project <span style="color:var(--red)">Name</span> <span style="color:var(--purple)">Subtitle</span></h1>
<div class="subtitle">One-line mission statement</div>
</header>
<!-- sections with <h2>// Section <span class="red">Name</span></h2> ... -->
</div>
</body>
</html>
```
---
## 10. Checklist for "Does this match the style?"
- [ ] Background `#0a0e1a`, cards `#111827`, borders `#1e2d3d`.
- [ ] JetBrains Mono for structure, IBM Plex Sans for prose.
- [ ] Every section `<h2>` starts with a cyan `//`.
- [ ] Exactly one accent hue per category, reused across border + tag + code + flow node.
- [ ] Accent backgrounds are **tinted** (`rgba(accent, 0.100.15)`), never solid.
- [ ] Zero shadows, zero gradients, zero rounded > 10px.
- [ ] Tags are 9px uppercase mono with 1px letter-spacing.
- [ ] Container capped at 1400px, page padded `40px 60px`.
- [ ] No hover animations beyond border-color if any at all.

76
tasks/lessons.md Normal file
View File

@@ -0,0 +1,76 @@
---
type: lessons
project: 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 `uv` pour 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 instruction `RUN` Docker car le shell par défaut est `sh`, pas `bash`. Soit ajouter `SHELL ["/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 un `uv.lock` existera, basculer sur `uv 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 les `environment:` du compose avec `.env.example` dé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 dev` no-op, `database_url` dead-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_validator` Pydantic qui refuse de booter en `APP_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 compose` vs `podman compose` vs `podman-compose` legacy). Le piège classique `COMPOSE ?=` ne marche pas si on veut conditionner la valeur par défaut sur `ENGINE` — il faut `ifndef COMPOSE` + `ifeq ($(ENGINE),docker)`. Tous les targets restent compose-driven (`$(COMPOSE) exec`, etc.) ; seuls `volumes` / `inspect-health` / `logs-api` ont 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 sur `container_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 up` initial é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`, pas `postgres:16-alpine`). Le mode `short-name-mode=enforcing` fail sans TTY pour prompter. Docker accepte le même préfixe transparente. → Dorénavant tous les `image:` et `FROM …` des projets cross-engine sont qualifiés.
- **`.dockerignore` qui exclut `*.md` casse `pyproject.toml` qui référence `readme = "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 sur `title`** : la prop native est `string`, donc redéfinir `title?: ReactNode` produit TS2430. Pattern à retenir : `Omit<HTMLAttributes<…>, 'title'>` quand on overload `title`/`color`/`autoFocus` etc.
- **Podman-compose 1.x ne surfait pas les `HEALTHCHECK` du Dockerfile dans `podman inspect`** : il faut redéclarer le healthcheck dans le `docker-compose.yml` pour que `make inspect-health` voie réellement l'état. Bonus : c'est aussi plus portable.
- **Piège shell : `make up 2>&1 | tail -80` bloque** quand la sortie est petite, parce que `tail` bufferise 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`) puis `tail` séparément, ou utiliser le `Monitor` tool pour streamer.
- **`PODMAN_COMPOSE_WARNING_LOGS=false`** masque 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 `test` après `runtime`, le container `api` s'est mis à exécuter `python -m pytest` au lieu de `gunicorn`, en boucle (exit 1 → restart → exit 1). Fix : `target: runtime` explicite dans `docker-compose.yml`. Règle : **toujours préciser `target:` 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 utiliser `event.listens_for("after_parent_attach", ...)`. J'ai choisi la 1re option : explicite > magique.
- **Workflow Alembic en container** : `alembic revision --autogenerate` crée le fichier dans le container, qu'il faut `podman cp` vers l'host avant rebuild. Sinon perdu. Ajouter ce détail dans la doc M1 (et envisager un bind mount `dev` plus tard).
- **Bypass `APP_ENV` doit couvrir `dev` ET `test`** : un container test légitime ne doit pas avoir besoin de secrets prod-grade. `if self.APP_ENV in ("dev", "test"): return self`.
- **`pytest` dans le runtime image, c'est non.** Faire un stage `test` dédié (multi-stage `--target test`) qui étend `deps` + `dev extras` + `tests/`, lancé via `podman 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_fk` aurait é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 e2e` est la séquence canonique de fin de milestone.
## 2026-05-11 — M3 RBAC, groupes, users, invitations
- **`logging.LogRecord` réserve `name`** comme attribut interne (en plus de `message`, `levelname`, `pathname`, `filename`, `module`, `funcName`, `lineno`, `asctime`, `process`, `thread`, `args`). Donc `log.info("metamorph.x.created", extra={"name": entity.name})` lève `KeyError: "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 est `str | None = None`. Solution : lire `raw = request.get_json()` puis tester `"display_name" in raw` dans 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 helper `triState(raw, key, payload)`.
- **`limiter.reset()` flask-limiter** est public et clean — pas besoin de toucher à `limiter._storage`. À appeler dans `/diag/reset` quand le limiter est `enabled`. Toujours guarder avec `if limiter.enabled` pour ne pas planter en `APP_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é dans `app/core/rate_limit.py` avec un docstring explicite. Dev = ergonomics totale, prod/staging = limiter actif, test = désactivé.
- **Playwright `workers: 1` + `fullyParallel: false`** quand 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 une `page` neuve (BrowserContext fresh). Pas de partage de session entre tests du même `describe`. Helper `loginViaSpa()` à 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 : `storageState` global, 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 TRUNCATE `permissions` + `group_permissions` + `groups`), seul `/setup` re-déclenche le chemin de seed via `bootstrap_admin → seed_all`. Sans ce 2e appel, l'admin créé aurait `is_admin=True` mais le catalogue serait vide.
- **Snapshot UserView/GroupView détachés** : retourner des `@dataclass(frozen=True)` au lieu de l'ORM permet de fermer le `session_scope` immédiatement. Plus simple que `s.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 refusant `set_group_permissions(admin_group, !=all_codes)`. Future-proof : si on bouge le bypass à un check perm-based plus tard, l'invariant tient déjà. `SystemGroupProtected` réutilisé pour le 409.
- **Toujours rebuild front + recreate containers** : `make rebuild` ne 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 prochain `tasks/testing-m<N>.md`.
## 2026-05-10 — M2 auth, JWT, invitations
- **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`, …) via `email-validator` `globally_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=True` sur `localhost` HTTP** : modern browsers (Chrome ≥89, Firefox ≥75) traitent `localhost` comme un secure context et acceptent les cookies `Secure` même servis en HTTP. Donc on peut respecter la spec strictement (`Secure` toujours) sans casser le dev — pas besoin de gating par `APP_ENV`.
- **`getByLabel` de Playwright** prend le **nom accessible** de l'input. Quand un `<label>` enveloppe `input` + `<span>` hint + `<span>` error, le hint et l'error polluent le nom et `getByLabel('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-limiter` doit être désactivé en `APP_ENV=test`** sinon 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 utilise `EmailStr`. Ne pas s'en rendre compte donne un crash gunicorn worker au boot avec `ImportError: 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: runtime` explicite dans `docker-compose.yml`.
- **Refresh token rotation + chain revoke** : à chaque `/auth/refresh`, on marque l'ancien token `revoked_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 rebuild` ne recrée pas les containers** — il faut `make down && make up` aprè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 api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends 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 vaut `make down && APP_ENV=test make up`.
- **`/diag/reset` test-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é en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
<!--
Template for future entries:
## YYYY-MM-DD — short title
- bullet
- bullet
-->

174
tasks/spec.md Normal file
View File

@@ -0,0 +1,174 @@
---
type: spec
date: "2026-05-08"
tags: [spec, ready]
status: ready
project: Metamorph
---
# Metamorph — Spec
> Spec finalisée après tour de questions du 2026-05-08. §12 et §13 vides : prête pour l'exécution. Le tracking quotidien bascule sur `Templates/Project.md`.
## 1. Pitch (3 lignes max)
Plateforme web collaborative purple team : la red team saisit les tests réalisés (procédure, commande, horodatage), la blue team annote en parallèle ses preuves de détection (alertes, logs, fichiers).
À la fin de la mission, Metamorph génère un slide reveal.js synthétisant les tests par catégorie MITRE ATT&CK et leur statut de détection.
Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rôles, avec lien d'invitation et permissions cloisonnées.
## 2. Problème
- Le workflow actuel (Excel → mail → Excel) est fastidieux, non versionné, sans contrôle d'accès, sans cohérence d'horodatage.
- L'horodatage précis et la séparation temporelle entre tests sont critiques pour que la blue team corrèle correctement ses logs.
- Aucune traçabilité des contributions red vs blue, aucune garantie d'intégrité (red peut écraser un commentaire blue).
- Les purple sont récurrents : il faut pouvoir réutiliser des batteries de tests (templates) sans recopier.
## 3. Utilisateurs & cas d'usage
- **Acteurs** : Administrateurs, Red Teamers, Blue Teamers (rôles atomiques par groupe custom — voir §5 F1).
- **Scénarios principaux** :
1. **Admin** crée des tests unitaires (templates) classifiés MITRE ATT&CK et les regroupe en scénarios réutilisables.
2. **Admin** invite des utilisateurs via lien à usage unique, leur assigne un ou plusieurs groupes (perms atomiques).
3. **Red Teamer** crée une mission, l'associe à un client/cible, sélectionne des scénarios, assigne les membres.
4. **Red Teamer** exécute les tests manuellement (sur la machine cible ou via tunnel hors plateforme), saisit dans Metamorph la commande lancée, l'output et un timestamp auto-capturé (overridable).
5. **Blue Teamer** consulte la mission (visibilité whitebox dès le début), annote chaque test : niveau de détection (taxonomie configurable), commentaires markdown, fichiers de preuves (logs, captures, EVTX).
6. **Red Teamer** génère le slide de synthèse reveal.js et l'exporte en PDF.
7. **Utilisateur invité** crée son compte via le lien d'invitation, change son mot de passe, accède aux missions où il est assigné.
8. **Red Teamer** ne peut pas modifier les champs blue (perm `mission.write_blue_fields` absente) et inversement.
## 4. Périmètre
**In scope (MVP v1)**
- Auth locale JWT (access 1h / refresh 30j), Argon2id, min 8 chars.
- Lien d'invitation à usage unique (token URL, expiration 7j, hors mail).
- Bootstrap : token d'install affiché dans les logs au 1er démarrage pour créer le 1er admin via `/setup`.
- Groupes custom + permissions atomiques (familles : user/group/invitation, test_template, scenario_template, mission, mission.write_red_fields, mission.write_blue_fields). 3 groupes pré-seedés : `admin`, `redteam`, `blueteam`.
- CRUD tests unitaires (templates) avec classification MITRE Enterprise (Tactic + Technique + Sub-technique multi-tags).
- CRUD scénarios (groupements ordonnés de tests, drag-and-drop pour la position).
- CRUD missions (nom, client/cible, dates début/fin, membres red+blue assignés, description/ROE markdown, statut `draft → in_progress → completed → archived`).
- Snapshot des templates au moment de l'instanciation dans une mission (modifier un template ne touche pas les missions existantes).
- Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel.
- Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`).
- Workflow par test instance : `pending → executed → reviewed_by_blue` + voies `skipped / blocked`.
- Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures).
- Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints).
- Notifications in-app uniquement (badge + liste), pas de SMTP.
- Génération slide reveal.js standalone (un fichier HTML autoportant) basé sur `tasks/design.md`, avec export PDF côté client (bouton intégré). Catégorisation par défaut MITRE Tactic, regroupement custom optionnel par mission.
- i18n FR + EN avec switch utilisateur.
- Soft delete partout + bouton « purge définitive » admin.
- Export d'une mission : JSON complet (API + UI) et CSV des résultats agrégés.
- Logs JSON structurés sur stdout, niveau configurable via `LOG_LEVEL`.
- Single-tenant + isolation stricte par mission : un utilisateur non-admin ne liste que les missions où il est membre.
**Out of scope v1 — explicitement exclu**
- Tunnel C2/ligolo (binaires, orchestration, exécution distante).
- Intégration Keycloak / OIDC.
- Audit log immuable et versioning des contenus.
- 2FA (TOTP/WebAuthn).
- SMTP / envoi de mail (notifications, invitations).
- Antivirus / scan ClamAV des uploads.
- Multi-tenancy / workspaces.
- Notifications mail.
- Logos / branding personnalisable.
**Nice-to-have — backlog v2+**
- Bascule auth vers Keycloak (OIDC, SSO).
- API d'ingestion pour qu'un C2 externe pousse les résultats automatiquement (hooks d'intégration).
- Audit log détaillé + versioning par champ critique.
- 2FA TOTP self-service.
- Notifications mail optionnelles.
- Intégration des binaires tunnel fournis par l'utilisateur pour l'exécution automatisée.
- Métriques Prometheus.
## 5. Exigences fonctionnelles
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4).
- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus.
- **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires.
- **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation.
- **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.
- **F6** — Saisie côté blue : niveau de détection (enum custom plateforme), commentaires markdown, multi-fichiers (whitelist).
- **F7** — Génération slide reveal.js standalone + export PDF client, groupé par MITRE Tactic (custom optionnel).
- **F8** — Notifications in-app (badge + flux) à chaque transition de statut d'un test concernant l'utilisateur.
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
- **F10** — Soft delete + purge admin.
- **F11** — Switch i18n FR/EN par utilisateur (préférence persistée).
- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti.
## 6. Exigences non fonctionnelles
- **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves.
- **NF-platform** : Debian x64 dernière stable, déploiement docker-compose (api Flask + Postgres + front nginx statique).
- **NF-network** : connectivité requise vers la DB Postgres (réseau interne compose). TLS terminé par un reverse proxy externe (à l'opérateur de la prod). Pas de connectivité sortante requise sauf sync MITRE manuelle.
- **NF-state** : PostgreSQL pour toutes les données structurées (volume Docker `metamorph_db`). Fichiers de preuves stockés sous `/data/evidence/<mission_id>/<test_id>/<sha256>` (volume Docker `metamorph_evidence`). Rétention indéfinie tant que non purgée.
- **NF-observability** : logs JSON sur stdout (champs : ts, level, msg, request_id, user_id, action), `LOG_LEVEL` env. Pas de métriques Prometheus en v1.
- **NF-security** : Argon2id, JWT signés HS256 (clé via env `JWT_SECRET`), CSRF non requis (Bearer token), CORS strict (origin du front uniquement), rate-limit basique sur `/auth/*` (10 req/min/IP). Permissions vérifiées côté serveur sur chaque endpoint, pas seulement côté UI.
- **NF-i18n** : tous les libellés UI passent par un fichier de traduction. Données MITRE conservées en EN (officielles).
## 7. Contraintes techniques
- **Backend** : Python 3.12+, Flask, SQLAlchemy + Alembic (migrations), psycopg2/psycopg3, pyjwt, argon2-cffi, marshmallow ou pydantic v2 pour la validation.
- **Frontend** : React 18 + Vite + TypeScript + TailwindCSS + TanStack Query + react-router. Tokens design (couleurs, typo, espacements de `tasks/design.md`) traduits en `tailwind.config.ts` + composants RTOps réutilisables.
- **Slide** : reveal.js (CDN récupéré et servi en statique par le front), génération côté client à partir des données de l'API.
- **DB** : PostgreSQL 16+.
- **Build** : Linux. Livraison docker-compose (api, db, front-static-nginx). Dockerfile multi-stage par service. Makefile pour `dev`, `build`, `up`, `migrate`, `seed-mitre`.
- **Dépendances JS** : limitées au strict nécessaire ; chaque lib pinned. Bundle Vite, pas de CDN runtime.
- **i18n** : `react-i18next` côté front, `flask-babel` côté back pour les messages d'erreur API.
- **Logs** : `python-json-logger` ou équivalent.
## 8. Entrées / sorties / données
- **Inputs** :
- UI : saisie red (texte), saisie blue (texte + uploads multipart), uploads de fichiers (validation MIME + extension).
- Seed : dataset STIX MITRE ATT&CK Enterprise au premier `up` (ou commande `flask metamorph seed-mitre`).
- **Outputs** :
- Slide reveal.js HTML standalone (un fichier `.html` autoportant, généré côté serveur ou côté client à partir des données API).
- Export JSON mission complet (sans binaires de preuves).
- Export CSV des résultats agrégés (test, mission, statut, niveau détection, timestamp).
- **Modèle de données** (entités principales — détail dans `tasks/todo.md`) :
- `users`, `groups`, `permissions`, `user_groups`, `group_permissions`, `invitations`
- `mitre_tactics`, `mitre_techniques`, `mitre_subtechniques`
- `test_templates`, `scenario_templates`, `scenario_template_tests` (jointure ordonnée)
- `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state d'exécution), `mission_categories` (custom)
- `evidence_files` (FK `mission_test_id`, sha256, mime, size, path)
- `notifications` (in-app)
- `detection_levels` (taxonomie custom, seedée avec 4 niveaux par défaut)
- `settings` (clés plateforme, ex: `mitre_last_sync`)
## 9. Interfaces
- **UI Web** (seule interface utilisateur). Design : `tasks/design.md` strictement (palette, typo JetBrains Mono / IBM Plex Sans, cards bordées par accent, comment-style headings `// Section`).
- **API REST JSON** consommée par le front (préfixe `/api/v1`). Auth Bearer JWT. Endpoints : `/auth/*`, `/users`, `/groups`, `/invitations`, `/test-templates`, `/scenario-templates`, `/missions`, `/missions/:id/tests/:test_id`, `/missions/:id/tests/:test_id/evidence`, `/missions/:id/export.json`, `/missions/:id/export.csv`, `/missions/:id/slide.html`, `/mitre/sync`, `/notifications`, `/detection-levels`, `/settings`. Schéma OpenAPI généré (flask-smorest ou apispec).
- **CLI Flask** (admin opérations) : `flask metamorph create-admin` (fallback), `flask metamorph seed-mitre`, `flask metamorph print-install-token`, `flask metamorph purge-soft-deleted`.
## 10. Critères de succès / Definition of Done
1. Premier boot : token d'install affiché dans les logs, accès `/setup` permet de créer le 1er admin.
2. Admin crée un groupe custom et lui attribue des permissions atomiques (write_red_fields uniquement, par exemple).
3. Admin envoie un lien d'invitation, l'utilisateur s'enregistre avec succès et hérite des bons groupes.
4. Admin importe la matrice MITRE et crée un test unitaire avec Tactic+Technique+Sub-technique.
5. Admin compose un scénario de 3 tests ordonnés via drag-and-drop.
6. Red Teamer crée une mission avec scénario, assigne 1 red + 1 blue.
7. Red Teamer marque un test « exécuté », saisit commande+output, timestamp auto capturé.
8. Blue Teamer voit la notification, annote avec niveau de détection + 2 fichiers de preuves (PDF + .evtx, < 25 Mo).
9. Red Teamer ne peut pas (HTTP 403) écrire dans les champs blue ; idem inverse.
10. Red Teamer génère le slide reveal.js, vérifie le rendu (catégorisation MITRE, accents couleur design.md), exporte en PDF côté navigateur.
11. Admin exporte la mission en JSON et CSV.
12. Admin soft-delete un test ; admin purge définitivement.
13. Switch i18n FR ↔ EN persiste entre sessions.
14. `docker compose up` depuis zéro produit un déploiement fonctionnel sur Debian x64.
15. Logs API en JSON sur stdout, lisibles avec `journalctl`/`docker logs`.
## 11. Risques & inconnues
- **Techniques**
- Génération slide reveal.js « standalone » avec données dynamiques : à valider qu'on inline correctement les données et ressources sans dépendre du back une fois exporté.
- Performances upload preuves multi-fichiers (25 Mo × N) : streaming côté Flask + limite globale par requête à fixer.
- Snapshot vs référence : bien isoler les tables `mission_tests` des `test_templates` à l'instanciation pour ne pas drift.
- **OPSEC** : faible. La plateforme est utilisée en interne avec consentement (purple team avec blue informée).
- **Inconnues levées** : tunnel/C2 reporté en v2, cloisonnement multi-tenant non requis, audit log non requis pour MVP.
## 12. Hypothèses à valider
*(vide — toutes les zones de flou ont été levées par le tour de questions du 2026-05-08)*
## 13. Questions ouvertes pour Claude
*(vide — prêt à passer en exécution)*
---
## Liens
- Project tracking : [[Projects/Metamorph]]
- Design system : `tasks/design.md`
- Plan d'exécution : `tasks/todo.md` (à créer)
- Wiki connexes :
- Troubleshooting :

247
tasks/testing-m0.md Normal file
View File

@@ -0,0 +1,247 @@
---
type: testing
project: Metamorph
milestone: M0
date: "2026-05-10"
---
# Comment tester M0 (bootstrap)
> Procédure de validation manuelle + automatisée pour le milestone M0. Toutes les commandes se lancent depuis la racine du repo.
## 0. Prérequis
Au choix entre Docker **ou** Podman — le Makefile détecte automatiquement (override `ENGINE=docker` ou `ENGINE=podman` si les deux sont installés).
| Outil | Version min | Vérifier |
|--------------------------------|-------------------|-------------------------------------------|
| **Docker Engine** *(option A)* | 24+ | `docker --version` + `docker compose version` |
| **Podman** *(option B)* | 4.0+ avec plugin compose, ou podman-compose 1.0.6+ | `podman --version` + `podman compose version` (ou `podman-compose --version`) |
| GNU make | 4+ | `make --version` |
| curl, jq | n'importe | `curl --version` |
| Node.js (pour les e2e) | 20+ | `node --version` |
Vérifier le moteur que le Makefile utilisera :
```bash
make engine
# ENGINE=podman
# COMPOSE=podman compose
```
Override possible : `make up ENGINE=docker COMPOSE="docker compose"`.
## 1. Bootstrap de l'environnement
```bash
make env # crée .env depuis .env.example
$EDITOR .env # vérifie : APP_ENV=dev OK pour la machine, sinon set des secrets forts
```
Variables critiques de `.env` :
- `APP_ENV``dev` autorise les placeholders ; `prod` ou `staging` exigent `JWT_SECRET >=32 chars` + `POSTGRES_PASSWORD` non-default (sinon l'API refuse de booter).
- `JWT_SECRET` — pour M0 le démon ne signe rien, mais autant le mettre propre tout de suite : `python3 -c "import secrets; print(secrets.token_urlsafe(64))"`.
- `HOST_FRONT_PORT` / `HOST_API_PORT` — modifie si 8080/8000 sont déjà occupés.
## 2. Build & démarrage
```bash
make up # build des 3 images + démarrage
make ps # vérifie que les 3 services tournent
```
**Attendu** :
```
metamorph-db postgres:16-alpine Up (healthy)
metamorph-api metamorph-api Up
metamorph-front metamorph-front Up (healthy)
```
Si `db` reste en `starting` au-delà de 30 s : `make logs` pour voir l'erreur (généralement un mismatch de credentials dans `.env`).
## 3. Tests fonctionnels manuels
### 3.1 — Health API direct (port 8000)
```bash
curl -s http://localhost:8000/api/v1/health | jq
```
**Attendu** :
```json
{ "status": "ok", "version": "0.1.0" }
```
### 3.2 — Health API via le proxy nginx (port 8080)
```bash
curl -s http://localhost:8080/api/v1/health | jq
```
Doit renvoyer le **même** JSON. Cela valide la conf nginx qui proxifie `/api/* → api:8000`.
### 3.3 — SPA dans le navigateur
Ouvrir <http://localhost:8080>. **Vérifications visuelles** :
- [ ] Header centré, fond `#0a0e1a`, titre « Metamorph Purple Team Platform » avec « Meta » en rouge et « Purple Team Platform » en violet.
- [ ] Section `// System Health` avec une card bordée vert affichant `version 0.1.0` et `status: ok`.
- [ ] Section `// Design Tokens` montrant les tags colorés (EVASION, C2, LATERAL…), la flow chain `recon → phish → c2 → lateral → impact`, et 3 boutons.
- [ ] Footer en mono dim avec la mention M0 bootstrap.
- [ ] Toutes les polices sont chargées (titres en JetBrains Mono, body en IBM Plex Sans). Onglet Network : **aucune** requête vers `fonts.googleapis.com` ou `fonts.gstatic.com`.
- [ ] Console JS sans aucune erreur.
### 3.4 — Logs structurés JSON
```bash
make logs-api # tail uniquement le container api (engine-agnostic)
```
**Attendu** : chaque ligne est un objet JSON avec au minimum `ts`, `level`, `logger`, `message`. Exemple :
```json
{"ts":"2026-05-10 14:21:33,012","level":"INFO","logger":"metamorph.boot","message":"metamorph.api.boot","cors_origins":["http://localhost:8080"],"log_level":"INFO","evidence_dir":"/data/evidence"}
```
Si tu vois du texte non-JSON, c'est gunicorn qui parle ; vérifier que l'app est bien chargée via `app.main:app` (le formatter doit s'appliquer).
### 3.5 — Healthchecks containers
```bash
make inspect-health
```
**Attendu** : `healthy` pour les trois containers (à 30 s près après le boot).
```
metamorph-db healthy
metamorph-api healthy
metamorph-front healthy
```
### 3.6 — Garde APP_ENV (sécurité)
Test négatif : on prouve que l'API refuse de booter en non-dev avec un secret faible.
```bash
make down
APP_ENV=prod JWT_SECRET=trop-court $(make engine | sed -n 's/^COMPOSE=//p') up api 2>&1 | head -30
# ou plus simplement, en explicitant ton moteur :
# APP_ENV=prod JWT_SECRET=trop-court docker compose up api
# APP_ENV=prod JWT_SECRET=trop-court podman compose up api
```
**Attendu** : trace d'erreur Pydantic mentionnant *"JWT_SECRET is missing, default, or shorter than 32 chars"*. L'API doit s'arrêter, pas démarrer.
Reset :
```bash
make down && make up
```
### 3.7 — CORS
```bash
curl -is -H 'Origin: http://localhost:8080' http://localhost:8080/api/v1/health \
| grep -i access-control-allow-origin
```
**Attendu** : un header `Access-Control-Allow-Origin: http://localhost:8080`.
```bash
curl -is -H 'Origin: http://evil.example' http://localhost:8080/api/v1/health \
| grep -i access-control-allow-origin || echo "no CORS allow header (expected)"
```
**Attendu** : pas de header (origine non-allowée).
### 3.8 — Volumes persistants
```bash
make volumes
```
**Attendu** : deux volumes nommés (le préfixe peut varier selon le moteur) :
```
metamorph_db
metamorph_evidence
```
Test de persistance basique : `make down && make up` ne doit pas effacer les volumes ; seul `make clean` le fait (destructeur, demande explicite).
## 4. Tests automatisés (Playwright)
```bash
make e2e-install # à faire une seule fois (download chromium + deps OS)
make up # si la stack n'est pas déjà up
make e2e # lance la suite
make e2e-report # ouvre le rapport HTML
```
**Suite M0** (`e2e/tests/m0-smoke.spec.ts`) — 8 tests :
| # | Test | Couvre |
|---|-----------------------------------------------------------|-------------------------------------------------|
| 1 | home page loads and renders the RTOps header | Front + nginx + assets statiques |
| 2 | API health card eventually shows OK | Front → API via proxy `/api/*` |
| 3 | design system primitives render with the expected accents | Card / Tag / FlowNode / Button |
| 4 | body uses self-hosted IBM Plex Sans, no Google Fonts | Spec §7 « pas de CDN runtime » |
| 5 | background uses the RTOps deep navy token | Token `--bg = #0a0e1a` appliqué |
| 6 | no JS console errors on first load | Pas de regression silencieuse côté SPA |
| 7 | API health endpoint returns the expected JSON shape | Contrat API direct |
| 8 | CORS headers are set when the SPA origin asks for them | flask-cors configuré sur `FRONT_ORIGIN` |
Le rapport HTML (`e2e/playwright-report/index.html`) inclut, pour chaque test : steps, screenshots sur échec, vidéo sur retry, trace Playwright (timeline réseau + DOM).
Le rapport JUnit XML (`e2e/playwright-report/junit.xml`) est consommable directement par GitLab CI / GitHub Actions / Jenkins.
## 5. Tests unitaires backend
```bash
make test-api
```
**Attendu** : `tests/test_health.py::test_health_returns_ok PASSED`.
## 6. Lint & typecheck
```bash
make lint
```
Lance ruff (back), eslint + tsc --noEmit (front). Tout doit passer.
## 7. Critères de DoD M0 (extraits de `tasks/todo.md`)
- [ ] `make up` démarre les 3 conteneurs
- [ ] `curl http://localhost:8080/api/v1/health``{"status":"ok","version":"…"}`
- [ ] Front affiche la home RTOps (manuel + e2e #1, #3, #5)
- [ ] Logs JSON sur stdout (manuel #3.4)
- [ ] Volumes nommés présents (manuel #3.8)
- [ ] Suite Playwright M0 verte
- [ ] Rapport HTML disponible dans `e2e/playwright-report/`
## 8. Si quelque chose casse
| Symptôme | Diagnostic |
|-------------------------------------------------------|---------------------------------------------------------|
| `make up` plante en build du back | Probablement un download `uv` lent ; relancer ou `make rebuild` |
| API réponse 502 via le front | api pas encore healthy ; `make logs api` |
| Page blanche, console : `Failed to load module …` | Le bundle Vite n'a pas été produit ; `make rebuild` |
| Polices custom non chargées (fallback sans-serif visible) | Vérifier que `@fontsource/*` est bien dans `node_modules` du build context |
| Tests Playwright `Timeout … API health card` | API pas joignable depuis le navigateur ; tester `curl` d'abord |
| `make volumes` ne montre rien | Vérifier que la stack est `make up`. Sous Podman rootless, les volumes vivent dans `~/.local/share/containers/storage/volumes/`. |
| `make engine` annonce le mauvais moteur | Override : `make up ENGINE=docker COMPOSE="docker compose"` ou inverse pour podman. |
| `podman compose` indisponible mais `podman-compose` oui | Le Makefile fallback automatiquement, ou force-le : `COMPOSE=podman-compose make up`. |
## 9. Pièges connus (validés sur podman 5.x / Fedora 43)
- **Short-name resolution sous Podman** : si tu remplaces une image par son nom court (`postgres:16-alpine`), Podman échoue avec `short-name resolution enforced but cannot prompt without a TTY`. **Toujours utiliser `docker.io/library/<image>:<tag>`** (Docker accepte le préfixe transparente).
- **Premier `make up`** : compte ~3 min pour télécharger `postgres:16-alpine` + builder les images custom. Les builds suivants sont quasi instantanés grâce au cache.
- **`make inspect-health` montre `(no-healthcheck)` malgré le Dockerfile** : podman-compose 1.x ne propage pas les healthchecks du Dockerfile. Le projet redéclare les healthchecks dans `docker-compose.yml` pour cette raison.
- **`api` reste en `starting` ~15 s** avant de basculer healthy : c'est le `start_period: 10s` du healthcheck + 1 round de polling. Normal.
- **Volumes Podman rootless** : `~/.local/share/containers/storage/volumes/` au lieu de `/var/lib/docker/volumes/`. `make volumes` liste les bons volumes peu importe l'engine.
## 10. Teardown
```bash
make down # garde les volumes
make clean # supprime aussi les volumes (DESTRUCTEUR)
```

218
tasks/testing-m1.md Normal file
View File

@@ -0,0 +1,218 @@
---
type: testing
project: Metamorph
milestone: M1
date: "2026-05-10"
---
# Comment tester M1 (schéma DB & migrations)
> Procédure de validation manuelle + automatisée pour M1 : SQLAlchemy 2 + Alembic + 26 tables. Toutes les commandes se lancent depuis la racine du repo.
## 0. Prérequis
Voir `tasks/testing-m0.md §0` (Docker ou Podman, Make, Node 20+, etc.). Aucune dépendance Python locale n'est requise — pytest tourne dans un container éphémère bâti depuis le stage `test` du Dockerfile backend.
## 1. Bootstrap (si la stack n'est pas déjà up)
```bash
make env # crée .env si absent
make up # build + start de la stack (api / db / front)
make inspect-health # attends que les 3 soient healthy
```
## 2. Appliquer la migration
```bash
make migrate # alembic upgrade head dans le container api
make migrate-status # confirme la revision courante = head
```
**Attendu** :
```
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 24765a5014b6, initial schema
```
et `make migrate-status` :
```
24765a5014b6 (head)
---
24765a5014b6 (head)
```
## 3. Tests fonctionnels manuels
### 3.1 — Liste des tables
```bash
make psql # ouvre psql dans le container db
\dt # une fois dans psql
```
**Attendu** : **27 lignes** (26 tables métier + `alembic_version`) :
```
detection_levels, evidence_files, group_permissions, groups,
invitation_groups, invitations, mission_categories, mission_members,
mission_scenarios, mission_test_mitre_tags, mission_tests, missions,
mitre_subtechniques, mitre_tactics, mitre_technique_tactics,
mitre_techniques, notifications, permissions, refresh_tokens,
scenario_template_tests, scenario_templates, settings,
test_template_mitre_tags, test_templates, user_groups, users,
alembic_version
```
Compter via SQL :
```bash
podman exec metamorph-db psql -U metamorph -d metamorph -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'"
# 27
```
### 3.2 — Contraintes au niveau Postgres
```bash
podman exec metamorph-db psql -U metamorph -d metamorph -tAc \
"SELECT contype, count(*) FROM pg_constraint WHERE connamespace = 'public'::regnamespace GROUP BY contype ORDER BY contype"
```
**Attendu** :
- `c|9` — CHECK constraints (status valid, opsec_level valid, mitre_kind valid, exactly_one_mitre_fk uniquement sur `test_template_mitre_tags`, …)
- `f|32` — Foreign keys *(snapshot `mission_test_mitre_tags` n'a volontairement pas de FK MITRE)*
- `p|27` — Primary keys (1 par table)
- `u|14` — UNIQUE constraints
### 3.3 — Index partiels (soft delete + unread notifications)
```bash
podman exec metamorph-db psql -U metamorph -d metamorph -c \
"SELECT indexname FROM pg_indexes WHERE schemaname='public' AND indexdef ILIKE '%WHERE%' ORDER BY 1"
```
**Attendu** (12 indexes) :
- `ix_evidence_files_active`, `ix_groups_active`, `ix_missions_active`, `ix_mission_categories_active`, `ix_mission_scenarios_active`, `ix_mission_tests_active`, `ix_scenario_templates_active`, `ix_test_templates_active`, `ix_users_active` — soft-delete partiels (9)
- `ix_notifications_user_unread``WHERE read_at IS NULL`
- `uq_users_email_active`, `uq_groups_name_active` — uniques scopés aux lignes actives
### 3.4 — Test négatif d'un CHECK constraint (`exactly_one_mitre_fk`)
```sql
-- Dans psql, doit échouer :
INSERT INTO test_templates (id, name, opsec_level)
VALUES (gen_random_uuid(), 'tmp', 'low');
INSERT INTO mitre_tactics (id, external_id, short_name, name)
VALUES (gen_random_uuid(), 'TA0099', 'tmp', 'tmp');
-- (puis tente une insertion avec deux FK MITRE non null — bloqué par CHECK)
```
Couvert automatiquement par `tests/test_schema.py::test_exactly_one_mitre_fk_check_enforced`.
### 3.5 — Migration depuis DB totalement vide
```bash
make clean # DESTRUCTEUR — supprime aussi les volumes
make up # re-spawn db vierge
# attends que db soit healthy
make migrate # applique 0001_initial sur DB vide
podman exec metamorph-db psql -U metamorph -d metamorph -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'"
# attendu : 27
```
## 4. Tests automatisés
### 4.1 — Backend pytest (M1 schema integration)
```bash
make test-api
```
**Attendu** : `9 passed in <1s` (1 health M0 + 8 schema M1).
Détail des 8 tests M1 :
| # | Test | Couvre |
|---|---|---|
| 1 | `test_all_expected_tables_exist` | Liste exhaustive des 26 tables métier |
| 2 | `test_soft_delete_columns_present` | `deleted_at` sur 6 tables |
| 3 | `test_standard_timestamp_columns_present` | `created_at`+`updated_at` sur 5 tables |
| 4 | `test_partial_index_for_soft_delete` | Index `ix_<table>_active` partiel |
| 5 | `test_expected_foreign_keys` | 14 paires FK clés (red→users, blue→users, evidence→test, etc.) |
| 6 | `test_expected_check_constraints` | Les 10 CHECK constraints fonctionnelles |
| 7 | `test_alembic_at_head` | `SELECT version_num FROM alembic_version` non-vide |
| 8 | `test_exactly_one_mitre_fk_check_enforced` | Test négatif INSERT — viole le CHECK |
Le test runner s'appuie sur le stage `test` du Dockerfile backend (`--target test` avec uv et les dev extras), spawné en container éphémère sur le réseau du compose. Le runtime stays minimal.
### 4.2 — Suite e2e Playwright (M0 + M1)
```bash
make e2e
```
**Attendu** : `12 passed`. Détail :
- 8 tests M0 (smoke bootstrap)
- 4 tests M1 (`e2e/tests/m1-db.spec.ts`) :
1. `GET /api/v1/diag/db` renvoie une revision Alembic en hex et `table_count >= 26`
2. La home page rend la card « Database » avec le short-hash de la revision et le compteur
3. La card « Roadmap » indique « M0 + M1 done » et cite M2
4. Le footer mentionne `M0 bootstrap` + `M1 db schema`
Le rapport HTML est dans `e2e/playwright-report/`.
### 4.3 — Endpoint diagnostique direct
```bash
curl -s http://localhost:8080/api/v1/diag/db | jq
```
**Attendu** :
```json
{
"reachable": true,
"alembic_revision": "24765a5014b6",
"table_count": 27
}
```
Quand la DB est down (ex : `make down` sur le service `db` seul), l'endpoint renvoie `503` avec `{"reachable": false, "error": "database_unreachable"}`.
## 5. Génération d'une nouvelle migration (workflow dev)
```bash
# 1. Modifier un modèle dans backend/app/models/
# 2. Générer la migration via Alembic dans le container :
make migrate-revision MSG="add foo column to mission_tests"
# Le fichier est créé dans le container — copie-le sur l'host pour le commit :
podman cp metamorph-api:/app/alembic/versions/. backend/alembic/versions/
# 3. Relire le fichier généré, le formatter (`make fmt`)
# 4. Rebuild + apply :
make rebuild && make up && make migrate
```
## 6. DoD M1 — checklist (extraits de `tasks/todo.md`)
- [x] `make migrate` applique le schéma sur DB vide
- [x] `\dt` montre les 27 tables (26 métier + alembic_version)
- [x] FK + CHECK + indexes en place (32 FK / 9 CHECK / 14 UQ / 12 partial)
- [x] Naming convention Alembic stable (préfixes `pk_/fk_/ck_/uq_/ix_`)
- [x] Soft delete partout sauf jointures simples (`deleted_at` + index partiel)
- [x] Audit minimal (`created_at`/`updated_at`) sur les tables principales
- [x] Tests d'intégration pytest verts (9 passed)
- [x] M0 e2e ne régresse pas
## 7. Pièges connus
- **`COMPOSE` cible le dernier stage du Dockerfile par défaut** : si on ajoute un stage après `runtime` (ici `test`), il faut explicitement `target: runtime` dans `docker-compose.yml`. Sinon `make up` lance pytest au lieu de gunicorn — le container exit en boucle.
- **Alembic autogenerate dans le container** : le fichier est créé dans `/app/alembic/versions/` du container. Le récupérer sur l'host via `podman cp` avant rebuild, sinon perdu.
- **Post-write hook `ruff`** : retiré d'`alembic.ini` parce que ruff est dev-only et n'est pas dans l'image runtime. Formatter les migrations à la main avec `make fmt` après génération.
- **`change-me-strong` (placeholder de `.env.example`)** est rejeté par `model_validator` en `APP_ENV=prod`. Pour les tests on a élargi le bypass à `APP_ENV in ("dev", "test")`.
## 8. Teardown
```bash
make down # garde les volumes
make clean # supprime aussi les volumes (DESTRUCTEUR)
```

212
tasks/testing-m2.md Normal file
View File

@@ -0,0 +1,212 @@
---
type: testing
project: Metamorph
milestone: M2
date: "2026-05-10"
---
# Comment tester M2 (auth, bootstrap, invitations)
> Procédure de validation manuelle + automatisée pour M2 (auth JWT, /setup, invitations, RBAC). Toutes les commandes se lancent depuis la racine.
## 0. Prérequis
Voir `tasks/testing-m0.md §0`. M2 n'ajoute aucune dépendance host (le pytest tourne dans un container éphémère via `make test-api`).
## 1. Bootstrap stack vide → premier admin
```bash
make env
make up
make migrate # 27 tables (M1)
```
Récupère le token install dans les logs :
```bash
make logs-api | grep -E "INSTALL TOKEN" | tail -1
# ou : podman logs metamorph-api 2>&1 | grep "INSTALL TOKEN"
```
Si la stack a été utilisée et le token consommé, force-mint un nouveau :
```bash
make print-install-token-force
```
## 2. /setup — création du 1er admin via curl
```bash
TOKEN="<paste-token-here>"
curl -s -X POST http://localhost:8080/api/v1/setup \
-H "Content-Type: application/json" \
-d "{\"install_token\":\"$TOKEN\",\"email\":\"admin@metamorph.local\",\"password\":\"AdminPass1234!\"}" | jq
# Attendu : {"ok":true,"user_id":"..."}
```
Vérifie que `/setup` ne peut plus être consommé :
```bash
curl -s http://localhost:8080/api/v1/setup | jq
# Attendu : {"completed":true}
```
## 3. /setup via la SPA
Ouvrir http://127.0.0.1:8080/setup dans le navigateur. Le formulaire affiche :
- **Install token** (paste depuis les logs)
- **Admin email**
- **Display name (optional)**
- **Password** + **Confirm password** (≥ 8 chars)
Le bouton « Create admin » appelle `POST /api/v1/setup` puis redirige vers `/login`. Si la stack a déjà un admin, la page affiche « Already done · Go to login → ».
## 4. Login + /auth/me
```bash
LOGIN=$(curl -s -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}')
echo "$LOGIN" | jq
ACCESS=$(echo "$LOGIN" | jq -r .access_token)
curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq
# Attendu : {is_admin: true, groups: ["admin"], email: "...", ...}
```
Le cookie `metamorph_refresh` est dans `/tmp/cookies.txt` (HTTPOnly, scope `/api/v1/auth/`).
Côté SPA : `/login` → email + password → redirige vers `/`. Le header affiche l'email courant via le testid `me-email` (`<span data-testid="me-email">…</span>`).
## 5. Refresh rotation
```bash
curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/refresh | jq
# Attendu : nouveau access_token, le cookie refresh est rotaté
```
L'ancien refresh token devient `revoked_at IS NOT NULL` en base ; un 2e usage du même refresh révoque la chaîne entière (détection de réutilisation).
## 6. Invitation → register → 2e login
```bash
# Admin crée invitation
INV=$(curl -s -X POST http://localhost:8080/api/v1/invitations \
-H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
-d '{"email_hint":"alice@metamorph.local"}')
echo "$INV" | jq
INV_TOKEN=$(echo "$INV" | jq -r .token)
# Preview (anonyme)
curl -s "http://localhost:8080/api/v1/invitations/preview/$INV_TOKEN" | jq
# Acceptance
curl -s -X POST "http://localhost:8080/api/v1/invitations/accept/$INV_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq
# Login Alice
curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token | head -c 40
```
Côté SPA : ouvrir `http://127.0.0.1:8080/register?token=<INV_TOKEN>`. La page affiche le hint email, demande password + confirm, redirige vers `/login` après acceptance.
## 7. RBAC — non-admin reçoit 403
```bash
ALICE_ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token)
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:8080/api/v1/invitations \
-H "Authorization: Bearer $ALICE_ACCESS" -H "Content-Type: application/json" -d '{}'
# Attendu : HTTP 403
```
## 8. Change password (force logout-all)
```bash
curl -s -X POST http://localhost:8080/api/v1/auth/change-password \
-H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
-d '{"current_password":"AdminPass1234!","new_password":"AdminPass5678!"}' | jq
# Attendu : {"ok":true}
```
Tous les refresh tokens du user sont révoqués → toute autre session existante repasse par /login.
## 9. Profile (SPA)
Une fois loggué, ouvrir `/profile` :
- Carte **Identity** (email, display name, locale)
- Carte **Groups** (tags)
- Carte **Permissions** (admin → tag « ADMIN — bypasses checks »)
- Carte **Change password** (current + new + confirm → submit → redirige vers /login après 1.5 s)
Logout via le bouton du header → redirige vers `/login`. Tenter `/profile` sans session → redirige vers `/login`.
## 10. Tests automatisés
### 10.1 — Backend pytest
```bash
make test-api
```
**Attendu** : **24 passed** (1 health + 8 schema + 15 auth flow).
Détail M2 (`tests/test_auth_flow.py`) :
| Test | Couvre |
|---|---|
| setup_status_starts_uncompleted | /setup état initial |
| setup_creates_first_admin | bootstrap consomme le token, crée admin |
| setup_status_now_completed | idempotence |
| setup_replay_is_blocked | 409 si déjà fait |
| login_and_me | flux login → /me complet |
| login_with_wrong_password_returns_401 | aucune énumération |
| me_without_token_returns_401 | bearer requis |
| refresh_rotates_and_old_token_is_revoked | rotation chaîne |
| refresh_with_no_cookie_returns_401 | cookie obligatoire |
| logout_clears_cookie_and_is_idempotent | idempotence |
| admin_creates_invitation_and_invitee_accepts | flux complet |
| unauthenticated_cannot_create_invitation | auth requis |
| non_admin_cannot_create_invitation | RBAC 403 |
| used_invitation_cannot_be_accepted_twice | usage unique |
| change_password_revokes_all_refresh_tokens | logout-all |
### 10.2 — Playwright e2e
```bash
make e2e
```
**Attendu** : **20 passed** (8 M0 + 4 M1 + 8 M2).
Détail M2 (`e2e/tests/m2-auth.spec.ts`) :
| Test | Couvre |
|---|---|
| setup status is uncompleted before bootstrap | API contract |
| SPA setup form creates the first admin | UI /setup |
| SPA login works and reveals the profile page | UI /login + /profile |
| admin issues an invitation via the API and the front renders the registration form | UI /register?token=… |
| invitee submits the registration form and can log in | UI register accept |
| non-admin gets 403 on the admin invitations endpoint | RBAC |
| refresh token rotation works through the SPA | rotation |
| logout clears the session and redirects to login | logout |
Le rapport HTML : `e2e/playwright-report/index.html`. JUnit : `e2e/playwright-report/junit.xml`.
## 11. Pièges connus (M2 spécifiques)
- **Cookie `Secure` en HTTP** : `secure=True` fait rejeter le cookie par le browser sur HTTP. Métamorph utilise `secure = APP_ENV in ("prod","staging")` — en `dev`/`test` le cookie est non-secure. En prod, le reverse proxy externe doit terminer la TLS.
- **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`) avec `globally_deliverable=True`. Métamorph utilise un type `Email` permissif via regex (cf. `app/api/_validation.py`).
- **`getByLabel` Playwright** récupère le **nom accessible** de l'input. Si la `<label>` enveloppe l'input ET le hint, le hint pollue le nom → matchs ratés. Le composant `TextField` met le hint en sibling, pas à l'intérieur du `<label>`.
- **Rate-limit `/auth/login` (10/min)** : peut bloquer une suite de tests. `flask-limiter` est désactivé par construction quand `APP_ENV=test`.
- **`/diag/reset`** est exposé seulement quand `APP_ENV in ("dev","test")`. Truncate les tables auth + force-mint un nouveau token install. **Ne jamais activer en prod.**
- **Compose recreate** : `make rebuild` ne recrée pas les containers — il faut `make down && make up` après un changement front pour que nginx serve le nouveau bundle.
## 12. Teardown
```bash
make down
# ou full reset (DESTRUCTEUR) :
make clean
```

102
tasks/testing-m3.md Normal file
View File

@@ -0,0 +1,102 @@
---
type: testing
milestone: M3
date: "2026-05-11"
project: Metamorph
---
# Testing M3 — RBAC, groups, users, invitations
## 1. Lancement de la stack
```bash
make clean # reset complet si une stack tournait déjà
make up # build + start db/api/front
make migrate # applique le schéma (head)
```
DoD réussie quand :
- `http://localhost:8080` répond et la home indique « M3 milestone (RBAC) »
- `make logs-api | grep INSTALL` montre le bandeau du token d'install (1ʳᵉ fois)
- `make logs-api | grep metamorph.permissions.seeded` confirme `perms_total: 31` au boot
## 2. Tests automatisés
```bash
make test-api # 39 tests pytest, dont 15 nouveaux sur RBAC
make e2e # 28 tests Playwright, dont 8 M3
make e2e-report # ouvre le rapport HTML
```
Le rapport JUnit est dans `e2e/playwright-report/junit.xml`.
## 3. Procédure manuelle (smoke navigateur)
### Pré-requis
1. Stack up via `make up`.
2. `make print-install-token` → noter le token (ou `make logs-api` pour le bandeau).
### 3.1 Bootstrap admin
1. Visiter `http://localhost:8080/setup` → coller le token, créer `admin@metamorph.local` / `AdminPass1234!`.
2. Redirection auto vers `/login` après ~1.5 s.
3. Se connecter avec les mêmes identifiants.
4. La barre de nav fait apparaître les liens **Users / Groups / Invitations** (visibles uniquement quand `is_admin === true`).
### 3.2 Page Groups (`/admin/groups`)
1. Cliquer **+ New group** → modale `new group`.
2. Nom : `pentest-2026-Q2`, description libre.
3. Cocher uniquement `mission.read` et `mission.write_red_fields` dans la grille de permissions.
4. **Create group** → la carte apparaît dans la liste avec les badges `2 perms`.
5. Ouvrir le groupe `admin` (système) — le champ `Name` doit être désactivé et la mention « System groups cannot be renamed. » visible.
6. Tenter de supprimer le groupe `admin` → bouton **Delete** invisible (les groupes système sont protégés côté UI **et** serveur).
### 3.3 Page Invitations (`/admin/invitations`)
1. Cliquer **+ New invitation** → modale `new invitation`.
2. Email hint : `alice@metamorph.local`. Cocher `pentest-2026-Q2`.
3. **Generate link** → modale `invitation link` montrant l'URL one-shot ; bouton **Copy** disponible.
4. Coller l'URL dans un onglet privé → page `/register` pré-remplie avec l'email hint. Compléter le mot de passe, valider.
### 3.4 Page Users (`/admin/users`)
1. Liste les comptes existants. Chercher `alice` dans le champ **Search**.
2. Cliquer **Edit** sur Alice → modale `alice@metamorph.local`. Vérifier ses groupes (cochés : `pentest-2026-Q2`).
3. Décocher `pentest-2026-Q2`, cocher `redteam`. **Save changes**.
4. Recharger la page → Alice a maintenant le badge `redteam`.
### 3.5 Last-admin protection
1. Tenter de soft-delete l'admin courant via l'API :
```bash
ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token)
MY_ID=$(curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq -r .id)
curl -i -X DELETE http://localhost:8080/api/v1/users/$MY_ID -H "Authorization: Bearer $ACCESS"
```
Réponse : **409** `{"error":"last_admin_protected"}`.
### 3.6 Vérification RBAC côté serveur (non-admin)
1. Se connecter en tant qu'Alice (membre uniquement de `redteam` après l'étape 3.4) :
```bash
ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@metamorph.local","password":"<son password>"}' | jq -r .access_token)
```
2. `/users` → **403** : `redteam` n'a pas `user.read`.
3. `POST /groups` → **403** : `redteam` n'a pas `group.create`.
4. Naviguer dans le browser sur `/admin/users` → redirige vers `/` (RequireAdmin client-side).
### 3.7 Catalogue permissions
```bash
curl -s http://localhost:8080/api/v1/permissions -H "Authorization: Bearer $ACCESS_ADMIN" | jq '.items | length'
```
Doit retourner `31` (cf. `app/services/permissions_seed.py:PERMISSION_CATALOGUE`).
## 4. Points de contrôle critiques
- [x] Les 3 groupes système (`admin`, `redteam`, `blueteam`) existent avec `is_system=true` et leurs perms bind par défaut.
- [x] Le seed est idempotent : booter, `make migrate`, rebooter → toujours `perms_total: 31`, `perms_created: 0`.
- [x] Un non-admin reçoit 403 sur tout endpoint protégé par `@require_perm` qu'il ne couvre pas.
- [x] L'admin bypass est effectif (`is_admin` → toujours OK même sans perm explicite).
- [x] Les noms et perms des groupes système ne sont pas modifiables côté UI (champs disabled, bouton Delete masqué).
- [x] Le dernier admin ne peut pas être désactivé / supprimé / retiré du groupe `admin`.
- [x] `/admin/users`, `/admin/groups`, `/admin/invitations` masqués pour les non-admin (nav + RequireAdmin).
- [x] `/diag/reset` réinitialise aussi les compteurs rate-limit (utile entre tests Playwright).

284
tasks/todo.md Normal file
View File

@@ -0,0 +1,284 @@
---
type: todo
date: "2026-05-08"
tags: [todo, plan]
status: in_progress
project: Metamorph
spec: tasks/spec.md
---
# Metamorph — Plan d'implémentation
> Découpage en 14 milestones livrables indépendamment. Chaque milestone a une **DoD** vérifiable. Cocher au fil de l'eau, documenter les écarts dans `CHANGELOG.md`, retours d'expérience dans `tasks/lessons.md`.
## Convention
- ☐ = à faire · ☑ = fait · ⚠ = bloqué (commenter) · ↻ = en cours
- Branches : `feature/m<N>-<slug>` · commits : `feat(m<N>): …` / `fix(m<N>): …`
- Chaque PR doit : passer lint/typecheck, mettre à jour `CHANGELOG.md`, mettre à jour `README.md` si surface utilisateur.
- **Chaque milestone livre un fichier `tasks/testing-m<N>.md`** (procédure manuelle + automatisée) **et au moins un spec Playwright `e2e/tests/m<N>-*.spec.ts`**.
- À la fin de chaque milestone : lancer le subagent `spec-reviewer` (HARD RULE 4 du CLAUDE.md global) avant de marquer le milestone done.
---
## M0 — Bootstrap repo & infra ☐
**But** : squelette buildable de bout en bout sans aucune feature métier.
-`backend/` (Flask 3, Python 3.12, `pyproject.toml` avec uv ou poetry, structure `app/{api,core,db,models,services,i18n}`)
-`frontend/` (Vite + React 18 + TS strict, Tailwind 3, ESLint + Prettier, alias `@/`)
- ☐ Tokens design `tasks/design.md` traduits en `frontend/tailwind.config.ts` (palette CSS vars, typo JetBrains Mono / IBM Plex Sans, radii 3/4/6/10).
- ☐ Composants UI de base : `<Card>`, `<Tag>`, `<SectionHeader>` (avec `// `), `<FlowNode>`, `<Button>` — fidèles au design.
-`docker-compose.yml` : services `api`, `db` (postgres:16-alpine), `front` (nginx servant le bundle Vite).
- ☐ Dockerfile multi-stage par service ; volumes nommés `metamorph_db`, `metamorph_evidence`.
-`Makefile` : `dev`, `build`, `up`, `down`, `migrate`, `seed-mitre`, `lint`, `test`.
- ☐ Pré-commit hook : `ruff` (back), `eslint`+`tsc --noEmit` (front).
-`README.md` minimal (run en dev, run en prod, variables d'env attendues).
-`.gitignore` : `.env`, `*.exe`, `*.dll`, `__pycache__/`, `node_modules/`, `dist/`, `data/`.
-`.env.example` documenté (`POSTGRES_*`, `JWT_SECRET`, `LOG_LEVEL`, `FRONT_ORIGIN`).
- ☐ Logs JSON structurés sur stdout (`python-json-logger`).
**DoD** : `make up` démarre les 3 conteneurs ; `curl http://localhost:${HOST_FRONT_PORT:-8080}/api/v1/health` renvoie `{ "status": "ok", "version": "..." }` (proxifié par nginx via `api:8000`) ; le front sur `:8080` affiche une page d'accueil au design RTOps ; **`make e2e` passe les 8 tests Playwright** ; rapport HTML dans `e2e/playwright-report/`. Procédure complète : `tasks/testing-m0.md`. *En prod, la TLS est terminée par un reverse proxy externe (cf. spec §6 NF-network) — la stack compose ne sert que du HTTP.*
---
## M1 — Schéma DB & migrations Alembic ☐
**But** : modèle de données complet versionné, sans logique métier.
- ☐ Configurer SQLAlchemy 2.x + Alembic.
- ☐ Tables auth/RBAC : `users`, `groups`, `permissions`, `user_groups`, `group_permissions`, `invitations`, `refresh_tokens`.
- ☐ Tables MITRE : `mitre_tactics`, `mitre_techniques`, `mitre_subtechniques` (avec `external_id`, `name`, `description`, `url`).
- ☐ Tables templates : `test_templates`, `test_template_mitre_tags` (jointure many-to-many tactic/technique/subtechnique), `scenario_templates`, `scenario_template_tests` (avec `position`).
- ☐ Tables missions : `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state), `mission_test_mitre_tags`, `mission_categories` (custom).
- ☐ Tables exécution : `evidence_files` (FK `mission_test_id`, `sha256`, `mime`, `size_bytes`, `storage_path`, `original_filename`).
- ☐ Tables paramétrage : `detection_levels` (clé, label_fr, label_en, color_token, position, is_default), `settings` (key/value).
- ☐ Table notifications : `notifications` (FK user, type, payload JSONB, read_at, created_at).
- ☐ Soft delete : colonne `deleted_at` partout sauf tables jointures simples ; index partiel `WHERE deleted_at IS NULL`.
- ☐ Audit minimal : `created_at`, `updated_at` partout.
- ☐ Migration initiale Alembic + commande `make migrate`.
**DoD** : `make migrate` applique le schéma sur une DB vide ; `\dt` montre toutes les tables ; les contraintes FK et les index sont en place.
---
## M2 — Auth, bootstrap, invitations ☑
**But** : un humain peut s'inscrire et se connecter.
- ☐ Hash mot de passe : `argon2-cffi` (params modérés, `time_cost=2, memory_cost=64MB`).
- ☐ JWT : `pyjwt`, HS256, claims `sub`, `iat`, `exp`, `type` (access|refresh), `jti`. Access 1h, refresh 30j.
- ☐ Stockage refresh tokens en DB (rotation à chaque usage, révocation au logout).
- ☐ Endpoints : `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`, `GET /auth/me`, `POST /auth/change-password`.
- ☐ Bootstrap : commande `flask metamorph print-install-token` génère + persiste un token unique au 1er démarrage (si table `users` vide), écrit dans les logs au boot.
- ☐ Endpoint `POST /setup` : consomme le token d'install, crée le 1er admin (groupe `admin` seedé).
- ☐ Invitations : `POST /invitations` (admin, génère token 7j), `GET /invitations/{token}` (preview), `POST /invitations/{token}/accept` (création compte avec password choisi).
- ☐ Middleware d'auth Flask (`@require_auth`, `@require_perm("...")`).
- ☐ Rate-limit `flask-limiter` sur `/auth/*` (10/min/IP).
- ☐ Front : pages `/login`, `/setup`, `/register?token=…`, `/profile`. Stockage access en mémoire, refresh en cookie HTTPOnly Secure SameSite=Strict.
- ☐ Hook React `useAuth()` + interceptor TanStack Query (refresh auto sur 401).
- ☐ CORS strict (origin `FRONT_ORIGIN`).
**DoD** : `flask metamorph print-install-token` → /setup → création admin → login → /auth/me OK ; admin crée invitation → user s'inscrit via lien → login OK ; `/auth/refresh` renouvelle correctement.
---
## M3 — RBAC : groupes, permissions, gestion users ☑
**But** : admin peut composer des groupes custom et y assigner des users.
- ☐ Seed des permissions atomiques (familles spec §4) :
- `user.{read,create,update,delete}`, `group.{read,create,update,delete}`, `invitation.{create,revoke,read}`
- `test_template.{read,create,update,delete}`, `scenario_template.{read,create,update,delete}`
- `mission.{read,create,update,archive,delete}`, `mission.write_red_fields`, `mission.write_blue_fields`
- `detection_level.{read,update}`, `setting.{read,update}`, `mitre.sync`
- ☐ Seed des 3 groupes par défaut (`admin` = toutes, `redteam` = templates(read) + missions(read,create,update) + write_red_fields, `blueteam` = templates(read) + missions(read) + write_blue_fields).
- ☐ Endpoints CRUD `groups`, `permissions` (lecture seule), `users` (admin), `users/{id}/groups` (assign).
- ☐ Décorateur `@require_perm` qui vérifie l'union des perms via tous les groupes du user.
- ☐ Front : page Admin > Users (liste, recherche, modale d'édition des groupes), Admin > Groups (CRUD + multi-select des perms), Admin > Invitations (liste, créer, révoquer).
- ☐ UI : on n'affiche pas les actions interdites (mais le serveur reste l'arbitre).
**DoD** : un admin peut créer un groupe `pentest-2026-Q2` avec uniquement `mission.read` + `mission.write_red_fields`, l'attribuer à Bob ; Bob voit les missions auxquelles il est membre mais ne peut pas écrire dans les champs blue (HTTP 403 au niveau API).
---
## M4 — MITRE ATT&CK Enterprise ☐
**But** : le référentiel ATT&CK est interrogeable et tagué sur les tests.
- ☐ Téléchargement initial du STIX bundle Enterprise depuis `github.com/mitre/cti` (vérifier hash, pin une version).
- ☐ Parser STIX → tables `mitre_tactics` / `mitre_techniques` / `mitre_subtechniques` (extraire `external_id` ATT&CK, `name`, `description`, `url`, relations technique↔tactic).
- ☐ Commande `flask metamorph seed-mitre [--source <path|url>]`.
- ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`).
- ☐ Persister `mitre_last_sync` dans `settings`.
- ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`).
- ☐ Front : composant `<MitreTagPicker>` (autocomplete combiné Tactic > Technique > Sub-technique, multi-select).
**DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.
---
## M5 — Templates : tests unitaires & scénarios ☐
**But** : admin peut bâtir le catalogue réutilisable.
- ☐ Modèle `test_template` : nom, description, objectif, procédure (markdown), prérequis (markdown), résultat attendu red, détection attendue blue, niveau OPSEC (`enum low/med/high`), tags libres (array text), IOCs attendus (array text), tags MITRE (multi).
- ☐ Endpoints CRUD `/test-templates` avec validation pydantic.
- ☐ Modèle `scenario_template` : nom, description, liste ordonnée de tests (`position`).
- ☐ Endpoints CRUD `/scenario-templates`, `PUT /scenario-templates/{id}/tests` (réordonnancement).
- ☐ Front : page Admin > Tests (liste filtrable par tactic / OPSEC / tag), modale d'édition (form complet avec markdown editor — `@uiw/react-md-editor` ou équivalent léger).
- ☐ Front : page Admin > Scénarios, drag-and-drop avec `@dnd-kit/sortable`.
- ☐ Filtres : recherche full-text sur nom/desc, facettes MITRE/OPSEC/tags.
**DoD** : admin crée 5 tests + 1 scénario de 3 tests réordonnés ; recharge la page → ordre persistant ; suppression soft-delete d'un template n'efface pas les scénarios.
---
## M6 — Missions & snapshot ☐
**But** : transformer les templates en missions vivantes.
- ☐ Modèle `mission` : nom, client/cible (texte), date_start, date_end, status (`enum draft/in_progress/completed/archived`), description (markdown), `visibility_mode` figé à `whitebox` v1.
-`mission_members` : (mission_id, user_id, role_hint `red|blue`) — rôle hint informatif, l'autorisation reste portée par les permissions.
- ☐ Lors de la création/modification d'une mission, sélection de scénarios → **snapshot** : copie complète des `scenario_templates` et `test_templates` dans `mission_scenarios` / `mission_tests` (y compris tags MITRE).
-`mission_tests` ajoute : `state` (`enum pending/executed/reviewed_by_blue/skipped/blocked`), `executed_at` (nullable), `executed_at_override` (bool), `red_command`, `red_output`, `red_comment`, `blue_comment`, `detection_level_id` (nullable).
- ☐ Endpoints : `POST /missions`, `GET /missions` (filtré par perms + membership pour les non-admin), `GET /missions/{id}` (avec scénarios+tests), `PUT /missions/{id}` (métadonnées + ajout de scénarios → snapshot), `POST /missions/{id}/transition` (drift de status), `DELETE /missions/{id}` (soft).
- ☐ Front : page Missions (liste + filtres status/client/dates), création (wizard 3 étapes : meta → scénarios → membres), vue mission (header + onglets Tests / Membres / Synthèse / Export).
- ☐ Vue mission : tableau des tests avec colonnes Tactic | Test | Statut | Niveau de détection | Last update, actions selon perms.
**DoD** : red crée une mission avec 1 scénario de 3 tests, ajoute Alice (red) et Bob (blue) ; modification ultérieure d'un test_template ne change rien dans la mission (snapshot préservé).
---
## M7 — Saisie red & blue sur un test ☐
**But** : exécution de la mission, le cœur du produit.
- ☐ Modale ou page dédiée `Mission > Test #N` avec deux zones distinctes (red / blue), bordures accentuées par couleur (rouge / cyan).
- ☐ Côté red : champ commande (mono), output (mono multiline), commentaire markdown, bouton « Marquer exécuté » qui set `state=executed` + `executed_at=now()` ; édition de `executed_at` derrière un toggle « override ».
- ☐ Côté blue : sélecteur `detection_level`, commentaire markdown, zone d'upload multi-fichiers (drag-and-drop).
- ☐ Upload preuves : `POST /missions/{id}/tests/{test_id}/evidence` (multipart, validation extension+MIME+taille≤25Mo, calcul SHA256, stockage `/data/evidence/<mission_id>/<test_id>/<sha256>{ext}`).
-`GET /evidence/{id}` (download, vérif perm) ; `DELETE /evidence/{id}` (soft).
- ☐ Permissions : tout endpoint d'écriture vérifie `mission.write_red_fields` ou `mission.write_blue_fields` selon le champ touché ; les deux peuvent coexister sur un même groupe (pas exclusifs en code).
- ☐ Bouton « Statut » avec choix `executed`, `reviewed_by_blue`, `skipped`, `blocked` (transitions contrôlées : pending↔skipped/blocked, executed→reviewed_by_blue).
- ☐ Indicateur « modifié par X il y a Ns » : polling `GET /missions/{id}/activity?since=…` toutes les 15 s tant que la page est active.
**DoD** : red et blue saisissent en parallèle sans conflit ; un user sans `write_blue_fields` reçoit 403 sur les champs blue ; un fichier .evtx de 24 Mo est uploadé, un de 26 Mo est rejeté ; le hash SHA256 est correct.
---
## M8 — Niveaux de détection custom ☐
**But** : la taxonomie d'icônes du slide est paramétrable.
- ☐ Seed initial : `detected_blocked` (red), `detected_alert` (orange), `logged_only` (yellow), `not_detected` (rose).
- ☐ Endpoints `/detection-levels` : list, create, update (label_fr, label_en, color_token, position, is_default).
- ☐ Garde-fou : empêcher la suppression si utilisé dans des `mission_tests` (proposer désactivation).
- ☐ Front : page Admin > Settings > Detection Levels (table + modale, picker de color_token parmi les 10 accents du design).
**DoD** : admin renomme `not_detected``missed`, ajoute `false_positive` avec accent purple ; les missions existantes affichent les nouveaux libellés ; un blueteamer voit la nouvelle option dans le sélecteur.
---
## M9 — Notifications in-app ☐
**But** : red et blue savent quand l'autre a agi.
- ☐ Service `notify(user_id, type, payload)` appelé sur transitions clés : `test_executed`, `test_reviewed_by_blue`, `evidence_added`, `mission_status_changed`.
- ☐ Endpoints `GET /notifications?unread_only=…`, `POST /notifications/{id}/read`, `POST /notifications/read-all`.
- ☐ Front : badge dans le header avec compteur, dropdown listant les 20 dernières + lien vers la mission/test.
- ☐ Polling `GET /notifications?unread_only=true` toutes les 30 s (ou WebSocket plus tard, hors scope).
**DoD** : Bob (blue) reçoit un badge « Test #4 prêt à review » 30 s max après qu'Alice (red) clique « Marquer exécuté ».
---
## M10 — Génération du slide reveal.js ☐
**But** : livrable client de fin de mission.
- ☐ Backend : endpoint `GET /missions/{id}/slide.html` qui calcule l'agrégat (tests groupés par MITRE Tactic, comptages par detection_level, plus regroupements custom si configurés).
- ☐ Côté serveur, on émet **un seul fichier HTML standalone** : reveal.js inliné (CSS + JS), tokens design.md inlinés, données JSON inlinées, **aucune ressource externe**.
- ☐ Layout : slide titre, slide « Méthodologie », une slide par Tactic avec liste des techniques/tests + icône colorée par detection_level, slide synthèse (matrice tactic × detection_level), slide annexes (preuves référencées par titre, sans binaires).
- ☐ Bouton « Export PDF » dans le slide → `print-pdf` reveal.js (`window.print()` + media query reveal).
- ☐ Front : page Mission > Synthèse avec preview iframe + bouton « Télécharger HTML ».
- ☐ Conformité design : `// ` headings en cyan, accents par detection_level, JetBrains Mono partout.
**DoD** : on télécharge `mission-X.html`, on l'ouvre offline dans Firefox/Chrome, navigation reveal OK, export PDF côté navigateur produit un PDF lisible.
---
## M11 — Exports JSON & CSV ☐
**But** : sortie des données brutes pour archivage.
-`GET /missions/{id}/export.json` : mission + scénarios + tests + niveaux de détection + métadonnées preuves (sans binaires, mais avec hash + filename).
-`GET /missions/{id}/export.csv` : une ligne par test (cols : test_name, mitre_tactic, mitre_technique, mitre_subtechnique, executed_at, status, red_command, detection_level, blue_comment_excerpt).
- ☐ Front : boutons d'export sur la page mission, headers `Content-Disposition: attachment`.
**DoD** : `curl -OJ` sur les deux endpoints donne deux fichiers cohérents et complets ; le JSON peut être réimporté dans le futur (laisser cet import en backlog).
---
## M12 — Soft delete & purge admin ☐
**But** : aucune perte de donnée par accident, ménage explicite.
- ☐ Toutes les `DELETE` du back deviennent `UPDATE deleted_at`.
- ☐ Tous les `GET` filtrent `deleted_at IS NULL` par défaut, paramètre `?include_deleted=true` réservé aux admins.
- ☐ Endpoint `POST /admin/purge` (perm admin) avec body `{entity, ids}` qui DELETE physiquement (suppression fichiers preuves incluse).
- ☐ Commande `flask metamorph purge-soft-deleted --older-than 30d` (manuelle, pas de cron auto).
- ☐ Front : page Admin > Trash (filtrée par entity), bouton « Restaurer » + bouton « Purger ».
**DoD** : suppression d'un test depuis l'UI → disparait des listes mais reste en DB ; admin peut le restaurer ; admin peut le purger définitivement, le fichier evidence associé disparait du disque.
---
## M13 — i18n FR / EN ☐
**But** : commutation de langue par utilisateur.
- ☐ Backend : `flask-babel`, deux locales `fr` / `en`. Messages d'erreur API via `gettext`. Fichier `messages.pot` extrait via `pybabel extract`.
- ☐ Frontend : `react-i18next`, namespaces par page, fichiers `frontend/src/i18n/{fr,en}/*.json`.
- ☐ Préférence user : champ `users.locale` (default `fr`), endpoint `PATCH /auth/me {locale}`, switch dans le header.
- ☐ Données MITRE conservées en EN (officielles, non traduites).
- ☐ Tous les libellés UI passent par `t('…')` — interdit le texte en dur.
**DoD** : Bob change sa langue en EN, recharge → toute l'UI en EN sauf les noms ATT&CK ; un message d'erreur API arrive aussi en EN.
---
## M14 — Polish, sécu, observabilité, doc ☐
**But** : prêt pour livraison.
- ☐ Logs JSON : `request_id`, `user_id`, `path`, `method`, `status`, `duration_ms`, `action` (libre côté service).
- ☐ Audit minimal : logger toute action sensible (`auth.login`, `mission.create`, `evidence.delete`, `admin.purge`).
- ☐ Rate-limit confirmé sur `/auth/*` et `/invitations/*`.
- ☐ Headers sécu : `Strict-Transport-Security` (si reverse proxy le pose, sinon doc), `Content-Security-Policy` strict côté front, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
- ☐ Validation : tailles max body globale (Flask `MAX_CONTENT_LENGTH`), schéma pydantic strict partout.
-`README.md` complet (déploiement, env, premier admin, sync MITRE, backup volumes).
-`CHANGELOG.md` à jour (Conventional changelog).
- ☐ Critères §10 de la spec : check 1 par 1 sur une démo end-to-end documentée dans `tasks/lessons.md`.
- ☐ Tests : pytest pour la logique critique (auth, RBAC, snapshot, upload, exports). Smoke E2E Playwright (non bloquant, but nice).
**DoD** : démo from-scratch sur Debian 13 — `git clone``make up` → setup admin → invite users → crée mission → exécute → annote → génère slide → export. Tous les 15 critères §10 spec validés.
---
## Backlog v2+ (rappel pour ne pas oublier)
- Bascule auth Keycloak/OIDC.
- API d'ingestion C2 externe (push automatique des résultats).
- Audit log détaillé + versioning par champ.
- 2FA TOTP self-service.
- Notifications mail.
- Intégration tunnel C2 (binaires fournis).
- Métriques Prometheus.
- Multi-tenancy / workspaces.
- Branding configurable (logos, couleurs).
---
## Hygiène de session
- Au début de chaque session : relire `tasks/lessons.md`, `CHANGELOG.md`, ce fichier.
- À la fin : mettre à jour les ☐/☑, ajouter une entrée `CHANGELOG.md`, capturer les apprentissages dans `tasks/lessons.md`.
- Pour tout doute architectural : repasser par AskUserQuestion avant d'ouvrir un éditeur.