docs(roadmap): A3-A8 cochés — bloc fallback admin corrigé, suite 1016/1016
FlowDeck CI / lint (push) Successful in 2m0s
FlowDeck CI / test (push) Successful in 22m31s
FlowDeck CI / docker (push) Successful in 1m45s

Commit d125eb3 (code + tests).
This commit is contained in:
2026-09-30 22:05:06 -04:00
parent d125eb399e
commit d76d7943fc
+7 -7
View File
@@ -1121,12 +1121,12 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.* - [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.* - [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
- [ ] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.* - [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
- [ ] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.* - [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
- [ ] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.* - [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
- [ ] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.* - [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
- [ ] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.* - [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
- [ ] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.* - [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.* - [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
- [ ] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.* - [ ] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
- [ ] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.* - [ ] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
@@ -1185,4 +1185,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1, A2, A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées).* → **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3`.**