Compare commits

...
Author SHA1 Message Date
bruno 99cd765997 [fix] CHANGELOG 2.80.2 : bouton Nouveau secret + plancher msgpack
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:41 -04:00
bruno 5266506b56 [fix] Securite : plancher msgpack>=1.2.3 (CVE-2026-114613, pip-audit bloquant)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m56s
2026-10-27 23:20:35 -04:00
bruno 682a5030d3 [fix] Coffre Secrets : valeurField attache l'oeil a wrap avant insertBefore (NotFoundError bouton Nouveau secret)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m19s
CI / test (pull_request) Failing after 1m47s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:30 -04:00
bruno fc3d847d85 [fix] mypy Coffre + bump 2.80.2 : CHANGELOG.md
CI / security (pull_request) Failing after 2m29s
CI / lint (pull_request) Successful in 2m55s
CI / test (pull_request) Failing after 5m10s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:10:51 -04:00
bruno 82756030c6 [fix] mypy Coffre + bump 2.80.2 : docs/ROADMAP.md 2026-10-27 23:10:48 -04:00
bruno 0352451ead [fix] mypy Coffre + bump 2.80.2 : README.fr.md 2026-10-27 23:10:44 -04:00
bruno 593cf492c3 [fix] mypy Coffre + bump 2.80.2 : README.md 2026-10-27 23:10:41 -04:00
bruno 0b8ad8b0f3 [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.lock 2026-10-27 23:10:39 -04:00
bruno 111d927ece [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.toml 2026-10-27 23:10:36 -04:00
bruno 2dae023ee3 [fix] mypy Coffre + bump 2.80.2 : desktop/tauri.conf.json 2026-10-27 23:10:34 -04:00
bruno b12122f78a [fix] mypy Coffre + bump 2.80.2 : package.json 2026-10-27 23:10:31 -04:00
bruno 0c7b3162c8 [fix] mypy Coffre + bump 2.80.2 : VERSION 2026-10-27 23:10:29 -04:00
bruno d4d050297b [fix] mypy Coffre + bump 2.80.2 : backend/coffre_store.py 2026-10-27 23:10:27 -04:00
bruno 8d768bf9d5 [fix] mypy Coffre + bump 2.80.2 : backend/coffre_crypto.py 2026-10-27 23:10:24 -04:00
bruno 47ac8bf6c0 Merge pull request '[fix] Version 2.80.1 synchronisee + corrections ruff (60 erreurs CI)' (#26) from fix/version-2.80.1-ruff into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 2m10s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:00:13 -04:00
bruno 01572736c0 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/webhooks.py
CI / security (pull_request) Successful in 1m57s
CI / lint (pull_request) Failing after 2m7s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:58:14 -04:00
bruno b6e5f01748 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/watcher.py 2026-10-27 22:58:11 -04:00
bruno 90da0ac990 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/tools/audit.py 2026-10-27 22:58:08 -04:00
bruno bc26d1506b [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/share.py 2026-10-27 22:58:05 -04:00
bruno 769e3ca559 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/vaults.py 2026-10-27 22:58:02 -04:00
bruno b5d789f02a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/recent.py 2026-10-27 22:57:59 -04:00
bruno c8dce25e0d [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/backups.py 2026-10-27 22:57:56 -04:00
bruno b322e2d24a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/scheduler.py 2026-10-27 22:57:53 -04:00
bruno 414ec69c0a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/realtime.py 2026-10-27 22:57:50 -04:00
bruno f5ca656c04 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/backups.py 2026-10-27 22:57:47 -04:00
bruno e158232aba [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/push.py 2026-10-27 22:57:44 -04:00
bruno 632871ac24 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/notify.py 2026-10-27 22:57:40 -04:00
bruno 7af7b2c8c2 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/indexer.py 2026-10-27 22:57:36 -04:00
bruno 97132770e4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/guide_export.py 2026-10-27 22:57:32 -04:00
bruno 683ad3ef94 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/export.py 2026-10-27 22:57:29 -04:00
bruno 604383f51c [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_store.py 2026-10-27 22:57:25 -04:00
bruno bdeb2f00b3 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_crypto.py 2026-10-27 22:57:21 -04:00
bruno 8688875fae [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/bookslm_routes.py 2026-10-27 22:57:17 -04:00
bruno a42271c7de [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/user_store.py 2026-10-27 22:57:13 -04:00
bruno cf4685eaf1 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/router.py 2026-10-27 22:57:10 -04:00
bruno 5023fb6774 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/audit.py 2026-10-27 22:57:06 -04:00
bruno 04eb70cbe4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/admin.py 2026-10-27 22:57:01 -04:00
bruno 4dc010f54d [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : CHANGELOG.md 2026-10-27 22:56:57 -04:00
bruno f7e36bc44e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : docs/ROADMAP.md 2026-10-27 22:56:53 -04:00
bruno a9ac1c06a7 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.fr.md 2026-10-27 22:56:48 -04:00
bruno 9bf6e7492f [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.md 2026-10-27 22:56:43 -04:00
bruno 14070cef43 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.lock 2026-10-27 22:56:37 -04:00
bruno 6b1abbb50e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.toml 2026-10-27 22:56:33 -04:00
bruno 46c641ebc5 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/tauri.conf.json 2026-10-27 22:56:30 -04:00
bruno 2d036f5126 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : package.json 2026-10-27 22:56:27 -04:00
bruno 2c69e357b8 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : VERSION 2026-10-27 22:56:25 -04:00
bruno ba81f22d40 Merge pull request '[Coffre Secrets] Phase 1 complète : chiffrement, API et UI (1a + 1b + 1c)' (#25) from feat/coffre-secrets-phase1 into main
CI / lint (push) Failing after 1m47s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
2026-10-27 22:29:09 -04:00
bruno 06c12d0383 [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (en)
CI / security (pull_request) Failing after 2s
CI / lint (pull_request) Failing after 1m55s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:08:23 -04:00
bruno 69f3968d4a [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (fr) 2026-10-27 22:08:20 -04:00
bruno d5ae66466a [Coffre Secrets] Phase 1c UI : styles vue Secrets et menu contextuel 2026-10-27 22:08:16 -04:00
bruno 2dc8be3127 [Coffre Secrets] Phase 1c UI : noeud virtuel Secrets sous Home-user dans sidebar.js (patron Partage #196) 2026-10-27 22:08:05 -04:00
bruno 040e80a36d [Coffre Secrets] Phase 1c UI : nouveau module frontend/js/coffres.js (vue Secrets, modales, copie manuelle) 2026-10-27 22:08:00 -04:00
bruno bd6b79fb41 [Coffre Secrets] Phase 1a/1b : tests crypto, store, autorisations, distribution 2026-10-27 21:45:58 -04:00
bruno 581a31161c [Coffre Secrets] Phase 1a : dependance cryptography pour AES-GCM 2026-10-27 21:45:56 -04:00
bruno 5f8f93ba6c [Coffre Secrets] Phase 1b : montage du routeur coffres a cote de config_router 2026-10-27 21:45:53 -04:00
bruno 40b3e9c5dd [Coffre Secrets] Phase 1b : routeur /api/coffres, autorisations, octrois, distribution 2026-10-27 21:45:40 -04:00
bruno 2ffc219f47 [Coffre Secrets] Phase 1b : log_coffre_event dans audit.log (sans valeur) 2026-10-27 21:45:38 -04:00
bruno 02394d3278 [Coffre Secrets] Phase 1a : store coffres/valeurs/octrois/consommateurs (0600, atomique, RLock) 2026-10-27 21:45:35 -04:00
bruno ff1ccca3cd [Coffre Secrets] Phase 1a : chiffrement par enveloppe AES-256-GCM (KEK/DEK) 2026-10-27 21:45:32 -04:00
Spark fddbaec82e feat: panneau Spark — version du service d'agents visible en mode sidebar #225
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-26 16:56:28 +00:00
Spark 8e073438a3 fix: URLs sœurs dérivées de l'environnement de l'agent — le menu inter-apps ne pointe plus la prod depuis la dev BUG-120
CI / security (push) Successful in 1m55s
CI / lint (push) Successful in 3m5s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 1m46s
CI / e2e (push) Successful in 18m9s
2026-10-26 00:06:16 +00:00
Spark 6972e9ec87 fix: planchers de sécurité pip-audit — brotli, filelock, opentelemetry-api, pypdf #224
CI / security (push) Successful in 2m42s
CI / lint (push) Successful in 3m22s
CI / test (push) Successful in 5m34s
CI / build (push) Successful in 2m48s
CI / e2e (push) Successful in 19m15s
2026-10-25 21:02:57 +00:00
Spark f29a22886a feat: URLs publiques de l'écosystème par configuration — dev/prod sans noms en dur #223
CI / lint (push) Successful in 3m11s
CI / security (push) Failing after 1m55s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 2m35s
CI / e2e (push) Successful in 18m24s
2026-10-25 18:10:59 +00:00
Spark 287afa1537 fix: CSP — frame-src autorise la console du service d'agents (mode console du panneau Spark) BUG-119
CI / lint (push) Successful in 3m5s
CI / security (push) Failing after 1m49s
CI / test (push) Successful in 5m19s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 18m9s
2026-10-24 03:10:34 +00:00
Spark b0ac6dfdb9 docs: #222 livré (2.77.0) — statut Roadmap
CI / lint (push) Successful in 2m58s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 5m10s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 18m2s
2026-10-23 04:13:51 +00:00
56 changed files with 4873 additions and 110 deletions
+28
View File
@@ -104,3 +104,31 @@ DEEPSEEK_MODEL=deepseek-chat
# OBSIGATE_GITEA_URL=https://git.example.net
# OBSIGATE_GITEA_TOKEN=
# OBSIGATE_GITHUB_TOKEN=
# ── Fusion / dev-prod — URL publiques de l'écosystème (#223) ──
# Aucun nom public n'est figé dans le code : ces variables portent les
# URL des autres applications. Valeurs par défaut (si absentes) = noms
# OFFICIELS de la production. Depuis BUG-120, les URL sœurs se dérivent
# de l'hôte de l'agent (agent<SUFFIXE>.dracodev.net, lu via
# AGENT_EVENEMENTS_URL / DRACO_AGENT_URL) : en dev, rien d'autre à
# déclarer pour obtenir les noms en -dev. Une variable explicite
# reste prioritaire sur la dérivation, par exemple :
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
# DRACO_AGENT_URL=https://agent.dracodev.net
# DRACO_OBSIGATE_URL=https://og.dracodev.net
# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
# DRACO_TIKTAK_URL=https://tiktak.dracodev.net
# DRACO_KANBANTE_URL=https://kanbante.dracodev.net
# DRACO_GITEA_URL=https://git.dracodev.net
# ── Fusion — émission d'événements vers le service d'agents (#220) ──
# URL complète de réception ; à défaut, dérivée de DRACO_AGENT_URL.
# AGENT_EVENEMENTS_URL=https://agent.dracodev.net/api/v1/evenements
# AGENT_EVENEMENTS_CLE=<clé propre à ObsiGate, jamais commitée>
# AGENT_EVENEMENTS_DOMAINES={"NomDuVault": "famille"}
# ── Fusion — émettrice SSO draco_session (#216/#217) ──
# DRACO_SESSION_SECRET=<secret partagé, jamais commité>
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
+7 -1
View File
@@ -180,7 +180,13 @@ jobs:
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
# l'extraction de texte et la lecture d'outlines — donc atteignables
# via backend/pdf_reader.py) sont corrigés par le plancher
# pypdf>=6.16.1 (BUG-093).
# pypdf>=6.16.1 (BUG-093), relevé à pypdf>=6.21.2 par #224 (11 CVE
# sur la 6.19.0 de la toolcache, corrigées au plus tard en 6.21.2).
# #224 ajoute les planchers brotli>=1.2.1 (PYSEC-2026-4322,
# GHSA-9v8h-qqgm-4m35), filelock>=4.2.1 (CVE-2026-112940,
# CVE-2026-112913, CVE-2026-112907) et opentelemetry-api>=1.45.1
# (CVE-2026-114603) : paquets transitifs ou préinstallés dans la
# toolcache, audités mais jamais alignés sans plancher explicite.
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
# corrigés par le plancher urllib3>=2.8.0.
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
+126 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.77.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.80.2**.
---
@@ -14,6 +14,131 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.80.2] — 2026-10-27
### Corrigé
- **CI lint (mypy)** — 3 erreurs dans le Coffre Secrets : annotation explicite `type[Any] | None` pour `AESGCM` dans `coffre_crypto.py` (le repli sans `cryptography` reste `None`, et `_require_aesgcm()` retourne désormais la classe pour un narrowing propre) ; annotation `dict[str, Any]` du coffre dans `create_coffre` (`coffre_store.py`), qui inférait ses valeurs en `str | None` et refusait le dict de `wrap_dek` ainsi que `coffre["id"]` en argument. Aucun `type: ignore` aveugle ajouté, aucun changement de comportement.
- **Bouton Nouveau secret inoperant (console NotFoundError)** — `valeurField` appelait `wrap.insertBefore(input, eyeBtn)` alors que le bouton oeil n'avait jamais ete rattache a `wrap` : l'oeil est desormais ajoute a `wrap` avant le premier `build`, la modale de creation (et celle de rotation) s'ouvre.
- **CI security (pip-audit)** — plancher `msgpack>=1.2.3` : la version transitive 1.2.1 etait signalee pour CVE-2026-114613 (correctif 1.2.3).
---
## [2.80.1] — 2026-10-27
### Ajouté
- **Coffre Secrets — Phase 1** (contenu de la release 2.80.0, dont l'entrée changelog manquait) : chiffrement par enveloppe AES-256-GCM, API `/api/coffres`, nœud virtuel Secrets sous le Home de l'utilisateur.
### Corrigé
- **Étiquette de version 2.80.0** — le tag v2.80.0 pointait sur un contenu dont le fichier `VERSION` valait encore 2.79.0 (un merge ne déclenche pas le bump automatique) ; `VERSION` et tous les fichiers dérivés passent à 2.80.1, pour que la version affichée corresponde au tag de la prochaine release.
- **CI lint (ruff)** — 60 erreurs corrigées dans `backend/` : alias `datetime.UTC` (Python 3.11+), `TimeoutError` natif, tri d'imports et import inutilisé dans `coffre_store.py`, `.encode()` sans argument redondant, `fromisoformat` gérant « Z » ; aucune règle ruff masquée, aucun changement de comportement.
---
## [2.79.0] — 2026-10-26
### Ajouté
- **Panneau Spark : version du service d'agents visible en mode
sidebar (#225)** — le pied du panneau Spark
(`frontend/draco-panneau.js`, asset partagé des 4 applications)
affiche la ligne discrète « Service d'agents vX », comme la
console pleine page. La version est lue en direct dans la santé
du service (`GET /healthz` de l'origine injectée par l'hôte), à
l'ouverture du panneau, avec cache de session ; jamais une
valeur codée en dur — service injoignable, rien d'affiché.
Demande de Bruno (2026-10-26), carte fusion #64 ; même
livraison : TikTak 0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
---
## [2.78.2] — 2026-10-26
### Corrigé
- **Menu inter-apps : les URL sœurs suivaient l'environnement de
l'agent (BUG-120, suite de #223)** — sur la dev
(`og-dev.dracodev.net`), le `window.DRACO_URLS` servi donnait
l'agent en `-dev` mais les applications sœurs (ObsiGate, Flowdeck,
TikTak, Kanbante) en noms **prod** : seule l'URL de l'agent avait
une dérivation de repli, et le `.env` dev ne déclare pas les quatre
`DRACO_<APP>_URL`. Résolution d'une URL sœur dans
`backend/draco_config.py`, inchangée dans son contrat : (1) la
variable explicite prime ; (2) à défaut, **dérivation
d'environnement** depuis l'hôte de l'agent tel qu'ObsiGate le
connaît déjà (`agent_url()`, qui lit `AGENT_EVENEMENTS_URL`) — si
cet hôte est de la forme `agent<SUFFIXE>.dracodev.net`, l'URL sœur
devient `https://<slug><SUFFIXE>.dracodev.net` (`og`, `flowdeck`,
`tiktak`, `kanbante`) ; hôte hors motif (autre domaine, LAN, IP,
port) → **aucune dérivation**, jamais de lien deviné ; (3) défaut
officiel. L'URL Gitea ne change pas. Aucune variable supplémentaire
à déclarer en dev. Tests `tests/test_draco_config.py` étendus
(dérivation `-dev`, priorité de l'explicite, hôtes hors motif,
non-régression sans env). Redéploiement Docker requis.
---
## [2.78.1] — 2026-10-25
### Corrigé
- **Sécurité des dépendances — `pip-audit` rouge sur 2.78.0 (#224)** —
le job `security` du run CI 2303 signalait 17 vulnérabilités dans
4 paquets hérités de l'environnement du runner (versions
préinstallées dans la toolcache, jamais alignées car « already
satisfied » — même piège que BUG-093). Planchers explicites dans
`backend/requirements.txt` : `pypdf>=6.21.2` (11 CVE sur la 6.19.0,
dont CVE-2026-113786 et CVE-2026-115572/115585/115580),
`brotli>=1.2.1` (PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35),
`filelock>=4.2.1` (CVE-2026-112940, CVE-2026-112913,
CVE-2026-112907) et `opentelemetry-api>=1.45.1`
(CVE-2026-114603). Garde-fou `TestDependencySecurityFloors`
étendu ; aucun code applicatif touché, aucun nouvel ignore
`pip-audit`. Redéploiement Docker requis.
---
## [2.78.0] — 2026-10-25
### Modifié
- **Dev/prod sans noms publics en dur (#223, Fusion)** — toutes les URL
publiques de l'écosystème dracodev viennent désormais de la
configuration (`backend/draco_config.py` : `DRACO_AGENT_URL`,
`DRACO_OBSIGATE_URL`, `DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
`DRACO_KANBANTE_URL`, `DRACO_GITEA_URL` ; défauts = noms officiels,
l'environnement dev déclare ses noms en `-dev` dans son seul `.env`).
La CSP (`connect-src`/`frame-src`) dérive l'origine de l'agent de la
config (à défaut, de l'origine d'`AGENT_EVENEMENTS_URL`) ; le backend
injecte `window.DRACO_URLS` dans `index.html` pour le panneau Spark,
la barre d'apps et les liens inter-apps ; les références croisées
reconnaissent les hôtes officiels et configurés et leur URL
d'affichage est reconstruite depuis la config à la lecture (aucune
donnée stockée migrée). WebAuthn confirmé dérivé de la requête /
`OBSIGATE_WEBAUTHN_*` (aucun changement). Redéploiement Docker
requis ; en dev, déclarer les noms en `-dev` dans le `.env` du poste.
---
## [2.77.1] — 2026-10-24
### Corrigé
- **Panneau Spark : « mode console » bloqué par la CSP (BUG-119, Fusion)** —
la surimpression du panneau Spark V3 (#222) charge la console du service
d'agents (`https://agent.dracodev.net`) dans une iframe, mais la CSP servie
limitait `frame-src` à `'self' blob:` : le navigateur refusait le
chargement (« This content is blocked »). `frame-src` autorise désormais
aussi `https://agent.dracodev.net` — déjà présente dans `connect-src`
pour le panneau texte ; aucune autre directive n'est modifiée. Test de
non-régression dans `tests/test_security_headers.py`. Redéploiement
Docker requis pour l'activer côté ObsiGate.
---
## [2.77.0] — 2026-10-23
### Modifié
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.77.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.80.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.77.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.80.2).
---
*Projet : ObsiGate | Version : 2.77.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.80.2 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.77.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.80.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.77.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.80.2).
---
*Project: ObsiGate | Version: 2.77.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.80.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.77.0
2.80.2
+2 -2
View File
@@ -10,7 +10,7 @@ import logging
import os
import shutil
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
"disk_used_gb": disk_used_gb,
"disk_total_gb": disk_total_gb,
"uptime_seconds": int(_server_uptime_seconds()),
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
}
+12 -1
View File
@@ -55,6 +55,9 @@ import httpx
logger = logging.getLogger("obsigate.agent_journal")
SOURCE_APP = "obsigate"
# Défaut officiel, conservé pour référence : depuis #223, ``_url()``
# passe par la config centralisée (``backend.draco_config``) — jamais
# un nom d'environnement particulier.
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
DELAI_S = 2.0
@@ -72,7 +75,15 @@ def _cle() -> str:
def _url() -> str:
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
# #223 : AGENT_EVENEMENTS_URL d'abord ; à défaut, l'URL du service
# configurée (DRACO_AGENT_URL) complétée du chemin d'événements ;
# à défaut, le défaut officiel.
explicite = os.environ.get("AGENT_EVENEMENTS_URL", "").strip()
if explicite:
return explicite
from backend.draco_config import agent_url
return f"{agent_url()}/api/v1/evenements"
def actif() -> bool:
+41 -6
View File
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
import json
import logging
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger("obsigate.audit")
@@ -54,7 +54,7 @@ def log_file_save(
):
"""Log a file save (PUT) operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "file_save",
"username": username,
"ip": ip or "unknown",
@@ -72,7 +72,7 @@ def log_file_delete(
):
"""Log a file delete operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "file_delete",
"username": username,
"ip": ip or "unknown",
@@ -88,7 +88,7 @@ def log_config_change(
):
"""Log a configuration change."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "config_change",
"username": username,
"ip": ip or "unknown",
@@ -99,7 +99,7 @@ def log_config_change(
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
"""Log a vault addition."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "vault_add",
"username": username,
"ip": ip or "unknown",
@@ -111,7 +111,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | Non
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
"""Log a vault removal."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "vault_remove",
"username": username,
"ip": ip or "unknown",
@@ -119,6 +119,41 @@ def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
})
def log_coffre_event(
username: str,
action: str,
coffre: str | None = None,
secret_ref: str | None = None,
consommateur: str | None = None,
resultat: str | None = None,
contexte_id: str | None = None,
ip: str | None = None,
):
"""Journal d'un événement du coffre Secrets (Phase 1b).
Règle absolue : aucun champ ne contient jamais une valeur de secret,
un texte chiffré, un nonce ou un jeton. Seules des références
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
"""
entry = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"username": username,
"ip": ip or "unknown",
}
if coffre is not None:
entry["coffre"] = coffre
if secret_ref is not None:
entry["secret_ref"] = secret_ref
if consommateur is not None:
entry["consommateur"] = consommateur
if resultat is not None:
entry["resultat"] = resultat
if contexte_id is not None:
entry["contexte_id"] = contexte_id
_write_entry(entry)
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries.
+3 -2
View File
@@ -7,6 +7,7 @@ import binascii
import logging
import os
import re
from datetime import UTC
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator
@@ -696,7 +697,7 @@ async def mfa_webauthn_register(
Returns recovery codes when MFA is newly enabled (they were never issued).
"""
from datetime import datetime, timezone
from datetime import datetime
from .user_store import get_user, update_user
from .webauthn_mfa import complete_registration, resolve_relying_party
@@ -716,7 +717,7 @@ async def mfa_webauthn_register(
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
raise HTTPException(400, "Validation du credential WebAuthn échouée")
record["registered_at"] = datetime.now(timezone.utc).isoformat()
record["registered_at"] = datetime.now(UTC).isoformat()
creds = list(user.get("webauthn_credentials", []))
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
creds.append(record)
+7 -7
View File
@@ -8,7 +8,7 @@ import logging
import shutil
import threading
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from .password import hash_password
@@ -97,8 +97,8 @@ def create_user(
"active": True,
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(timezone.utc).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(),
"created_at": datetime.now(UTC).isoformat(),
"password_changed_at": datetime.now(UTC).timestamp(),
"last_login": None,
"failed_attempts": 0,
"locked_until": None,
@@ -126,7 +126,7 @@ def update_user(username: str, updates: dict) -> dict:
if "password" in safe_updates:
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
# BUG-028: invalidate every token issued before this change.
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
safe_updates["password_changed_at"] = datetime.now(UTC).timestamp()
data["users"][username].update(safe_updates)
_write(data)
@@ -148,7 +148,7 @@ def delete_user(username: str):
def record_login_success(username: str):
"""Record a successful login: update last_login, reset failed attempts."""
update_user(username, {
"last_login": datetime.now(timezone.utc).isoformat(),
"last_login": datetime.now(UTC).isoformat(),
"failed_attempts": 0,
"locked_until": None,
})
@@ -171,7 +171,7 @@ def record_login_failure(username: str) -> int:
# Lock after 5 failed attempts (15 minutes)
if attempts >= 5:
locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15)
datetime.now(UTC) + timedelta(minutes=15)
).isoformat()
updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
@@ -189,7 +189,7 @@ def is_locked(username: str) -> bool:
if not user or not user.get("locked_until"):
return False
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now(timezone.utc) > locked_until:
if datetime.now(UTC) > locked_until:
# Lock expired — unlock
update_user(username, {"locked_until": None, "failed_attempts": 0})
return False
+1 -1
View File
@@ -591,7 +591,7 @@ async def api_bookslm_agent(
while True:
try:
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
except asyncio.TimeoutError:
except TimeoutError:
if run_task.done():
break
continue
+215
View File
@@ -0,0 +1,215 @@
"""Chiffrement par enveloppe du coffre Secrets ObsiGate (Phase 1a).
Principe (spec §5) : deux niveaux de clés, jamais de valeur en clair au repos.
- KEK : clé maître 256 bits, fournie par l'opérateur via ``OBSIGATE_KEK``
(base64, 32 octets) ou ``OBSIGATE_KEK_FILE`` (fichier 0600, hors dépôt,
hors arborescence des vaults). Elle n'est JAMAIS dérivée du secret
d'application, jamais journalisée, jamais renvoyée par API.
- DEK : une clé aléatoire par coffre, stockée uniquement chiffrée par la KEK.
- Version de secret : ciphertext AES-256-GCM + nonce unique + tag GCM
(le tag est inclus dans le ciphertext renvoyé par ``AESGCM.encrypt``).
Un tag invalide ou un fichier altéré = refus, jamais de valeur fausse.
Le module reste importable sans ``cryptography`` installé : les opérations
échouent alors avec ``CryptoIndisponibleError`` au lieu de casser le
démarrage de l'application (le routeur répond 503 dans ce cas).
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import stat
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.coffre_crypto")
# Annotation explicite : sans elle, mypy refuse l'assignation de deux types
# (la classe AESGCM importée, puis None dans le repli sans cryptography)
# et le commentaire `type: ignore[assignment]` ne couvre pas ce code [misc].
AESGCM: type[Any] | None
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except Exception: # pragma: no cover - environnement sans cryptography
AESGCM = None
InvalidTag = Exception # type: ignore[assignment,misc]
class CoffreCryptoError(Exception):
"""Erreur générique du chiffrement du coffre."""
class KEKAbsenteError(CoffreCryptoError):
"""Aucune KEK configurée (ni OBSIGATE_KEK, ni OBSIGATE_KEK_FILE)."""
class KEKInvalideError(CoffreCryptoError):
"""KEK mal formée (ni base64 32 octets, ni hex 64 caractères)."""
class TagInvalideError(CoffreCryptoError):
"""Authentification GCM échouée : données altérées ou mauvaise clé."""
class CryptoIndisponibleError(CoffreCryptoError):
"""La bibliothèque ``cryptography`` n'est pas installée."""
def _require_aesgcm() -> type[Any]:
"""Retourne la classe AESGCM, ou lève si cryptography est absent.
Retourner la classe (plutôt qu'un simple garde) permet à mypy de
savoir que l'appelant détient bien un constructible, et non None.
"""
if AESGCM is None:
raise CryptoIndisponibleError(
"cryptography n'est pas installé (dépendance Phase 1a)"
)
return AESGCM
def parse_kek(raw: str) -> bytes:
"""Parse une KEK : base64 (32 octets) ou hex (64 caractères).
Accepte le base64 standard et URL-safe, avec ou sans padding.
"""
value = (raw or "").strip()
if not value:
raise KEKInvalideError("KEK vide")
# Hex d'abord si la forme correspond (64 caractères hex).
if len(value) == 64:
try:
decoded = bytes.fromhex(value)
except ValueError:
decoded = b""
if len(decoded) == 32:
return decoded
padded = value + "=" * (-len(value) % 4)
for decoder in (base64.urlsafe_b64decode, base64.b64decode):
try:
decoded = decoder(padded)
except (binascii.Error, ValueError):
continue
if len(decoded) == 32:
return decoded
raise KEKInvalideError("KEK invalide : attendu base64 de 32 octets")
def generate_kek_b64() -> str:
"""Génère une KEK 256 bits, encodée base64 (à sauvegarder hors dépôt)."""
return base64.b64encode(os.urandom(32)).decode("ascii")
def load_kek() -> bytes:
"""Charge la KEK depuis l'environnement ou le fichier dédié.
Priorité : ``OBSIGATE_KEK`` puis ``OBSIGATE_KEK_FILE``. Le fichier doit
être en 0600 (aucun droit groupe/autre) ; sinon, refus explicite.
"""
env_kek = os.environ.get("OBSIGATE_KEK", "").strip()
if env_kek:
return parse_kek(env_kek)
kek_file = os.environ.get("OBSIGATE_KEK_FILE", "").strip()
if kek_file:
path = Path(kek_file)
try:
mode = path.stat().st_mode
except OSError as exc:
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
if mode & (stat.S_IRWXG | stat.S_IRWXO):
raise KEKInvalideError(
"Fichier KEK trop permissif : attendu 0600, "
f"droits groupe/autre détectés sur {kek_file}"
)
try:
return parse_kek(path.read_text(encoding="utf-8"))
except OSError as exc:
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
raise KEKAbsenteError("Aucune KEK configurée (OBSIGATE_KEK / OBSIGATE_KEK_FILE)")
def kek_presente() -> bool:
"""True si une KEK valide est chargeable, False sinon (jamais d'exception)."""
try:
load_kek()
except CoffreCryptoError:
return False
return True
def generate_dek() -> bytes:
"""Génère une DEK aléatoire 256 bits pour un coffre."""
return os.urandom(32)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _unb64(value: str) -> bytes:
return base64.b64decode(value)
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
aesgcm = _require_aesgcm()
nonce = os.urandom(12)
ciphertext = aesgcm(key).encrypt(nonce, plaintext, aad)
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
aesgcm = _require_aesgcm()
try:
return aesgcm(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
except InvalidTag as exc:
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
except (binascii.Error, ValueError) as exc:
raise TagInvalideError("Données chiffrées mal formées") from exc
def wrap_dek(dek: bytes, kek: bytes, coffre_id: str) -> dict:
"""Chiffre une DEK avec la KEK (AAD = identifiant du coffre)."""
return encrypt_with_key(kek, dek, coffre_id.encode("utf-8"))
def unwrap_dek(wrapped: dict, kek: bytes, coffre_id: str) -> bytes:
"""Déchiffre une DEK avec la KEK."""
return decrypt_with_key(
kek, wrapped["nonce"], wrapped["ciphertext"], coffre_id.encode("utf-8")
)
def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict:
"""Chiffre une valeur de secret (AAD = secret_id:version)."""
aad = f"{secret_id}:{version}".encode()
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
def decrypt_secret_value(
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
) -> str:
"""Déchiffre une valeur de secret."""
aad = f"{secret_id}:{version}".encode()
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
def reencrypt_secret_value(
ancienne_dek: bytes,
nouvelle_dek: bytes,
secret_id: str,
version: int,
nonce_b64: str,
ciphertext_b64: str,
) -> dict:
"""Re-chiffre une version avec une nouvelle DEK (rotation DEK)."""
valeur = decrypt_secret_value(
ancienne_dek, secret_id, version, nonce_b64, ciphertext_b64
)
return encrypt_secret_value(nouvelle_dek, secret_id, version, valeur)
+860
View File
@@ -0,0 +1,860 @@
"""Store du coffre Secrets ObsiGate (Phase 1a).
Patron repris de ``backend/tools/secrets.py`` : JSON dans ``data/``,
écriture atomique (tmp + rename), verrou ``RLock`` sur les cycles
lecture-modification-écriture, répertoire honorant ``OBSIGATE_DATA_DIR``.
Fichiers (permissions 0600) :
- ``data/coffres.json`` : coffres (métadonnées + DEK chiffrées),
consommateurs (jetons hachés SHA-256 seulement, jamais en clair),
octrois (références, statuts, expirations).
- ``data/coffres_valeurs.json`` : versions chiffrées des secrets.
Aucune valeur en clair n'est jamais persistée ici, et aucune fonction de
ce module ne journalise de valeur ni de texte chiffré.
Modèle :
- Coffre : id, slug, nom, description, proprietaire, perimetre
(personnel | partage | instance), dek_chiffree (ou None en mode
dégradé sans KEK), statut (actif | archive), dates.
- Secret : id, coffre_id, nom, type, service, note, expiration,
version_courante, cree_le, modifie_le, derniere_utilisation.
- Version : numero, nonce, ciphertext, cree_le, remplacee_le.
Seule la version courante est distribuable.
- Consommateur : id (sert de jti), nom, jeton_hash, jeton_prefixe,
statut (actif | revoque), cree_par, cree_le.
- Octroi : id, coffre_id, secret_id (None = tout le coffre),
cible_type (consommateur | utilisateur | executeur | workspace),
cible_id, expiration, statut (actif | revoque | expire),
cree_par, cree_le, revoque_le.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import secrets as _secrets
import threading
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from backend.coffre_crypto import (
decrypt_secret_value,
encrypt_secret_value,
generate_dek,
load_kek,
parse_kek,
reencrypt_secret_value,
unwrap_dek,
wrap_dek,
)
logger = logging.getLogger("obsigate.coffre_store")
_lock = threading.RLock()
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
TYPES_SECRET = {"api_key", "token", "password", "certificate", "autre"}
PERIMETRES = {"personnel", "partage", "instance"}
CIBLES_OCTROI = {"consommateur", "utilisateur", "executeur", "workspace"}
MAX_VALEUR = 65536
MAX_TEXTE = 2000
DUREE_OCTROI_DEFAUT_JOURS = 90
# Motifs refusés dans les champs de métadonnées (description, note) :
# une valeur ressemblant à un secret ne doit jamais transiter par un champ
# qui, lui, est renvoyé en clair dans les métadonnées.
_SECRET_LIKE_PATTERNS = (
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"),
re.compile(r"\bgh[opsu]_[A-Za-z0-9]{8,}\b"),
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
re.compile(
r"(?i)\b(token|secret|mot_de_passe|password|api[-_]?key)\s*[:=]\s*\S+"
),
)
class StoreError(Exception):
"""Erreur générique du store."""
class CoffreExisteError(StoreError):
pass
class SecretExisteError(StoreError):
pass
class IntrouvableError(StoreError):
pass
class ValidationError(StoreError):
pass
class SecretExpireError(StoreError):
pass
def resolve_data_dir(data_dir: str | Path | None = None) -> Path:
"""Répertoire de persistance (OBSIGATE_DATA_DIR, défaut ``data``)."""
if data_dir is not None:
return Path(data_dir)
return Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
def _coffres_path(base: Path) -> Path:
return base / "coffres.json"
def _valeurs_path(base: Path) -> Path:
return base / "coffres_valeurs.json"
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _parse_date(value: str | None) -> datetime | None:
"""Parse une date ISO (datetime) ou une date seule (fin de journée UTC)."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
if len(text) == 10:
text = text + "T23:59:59+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError as exc:
raise ValidationError(f"Date invalide : {value!r}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bool:
"""True si la date d'expiration est passée (None = jamais)."""
parsed = _parse_date(expiration)
if parsed is None:
return False
ref = maintenant or datetime.now(UTC)
return parsed <= ref
def _read_json(path: Path, default: dict) -> dict:
if not path.exists():
return json.loads(json.dumps(default))
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("Store coffre illisible (%s) : %s", path, exc)
return json.loads(json.dumps(default))
return data if isinstance(data, dict) else json.loads(json.dumps(default))
def _write_json(path: Path, data: dict) -> None:
"""Écriture atomique tmp+rename, fichier final en 0600."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def _load_coffres(base: Path) -> dict:
return _read_json(
_coffres_path(base),
{"coffres": [], "consommateurs": [], "octrois": []},
)
def _load_valeurs(base: Path) -> dict:
return _read_json(_valeurs_path(base), {"versions": {}})
def _save_coffres(base: Path, data: dict) -> None:
_write_json(_coffres_path(base), data)
def _save_valeurs(base: Path, data: dict) -> None:
_write_json(_valeurs_path(base), data)
def valider_slug(slug: str) -> str:
value = (slug or "").strip().lower()
if not SLUG_RE.match(value):
raise ValidationError(
"Slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
)
return value
def valider_nom_secret(nom: str) -> str:
value = (nom or "").strip().lower()
if not NOM_SECRET_RE.match(value):
raise ValidationError(
"Nom de secret invalide : 3 à 128 caractères, minuscules, chiffres, - ou _"
)
return value
def refuser_valeur_dans_metadonnees(texte: str | None, champ: str) -> None:
"""Refuse une valeur ressemblant à un secret dans un champ métadonnée."""
if not texte:
return
for motif in _SECRET_LIKE_PATTERNS:
if motif.search(texte):
raise ValidationError(
f"Le champ {champ} ressemble à une valeur de secret ; "
"utilisez le champ valeur du secret, jamais une métadonnée"
)
def _valider_texte(texte: str | None, champ: str) -> str:
value = (texte or "").strip()
if len(value) > MAX_TEXTE:
raise ValidationError(f"Le champ {champ} dépasse {MAX_TEXTE} caractères")
refuser_valeur_dans_metadonnees(value, champ)
return value
# ---------------------------------------------------------------------------
# Coffres
# ---------------------------------------------------------------------------
def list_coffres(data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return list(_load_coffres(base)["coffres"])
def get_coffre(slug: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
slug = valider_slug(slug)
with _lock:
for coffre in _load_coffres(base)["coffres"]:
if coffre["slug"] == slug:
return coffre
return None
def get_coffre_or_404(slug: str, data_dir: str | Path | None = None) -> dict:
coffre = get_coffre(slug, data_dir)
if coffre is None:
raise IntrouvableError(f"Coffre introuvable : {slug}")
return coffre
def get_coffre_by_id(coffre_id: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
with _lock:
for coffre in _load_coffres(base)["coffres"]:
if coffre["id"] == coffre_id:
return coffre
return None
def get_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
with _lock:
for octroi in _load_coffres(base)["octrois"]:
if octroi["id"] == octroi_id:
return octroi
return None
def create_coffre(
slug: str,
nom: str,
proprietaire: str,
perimetre: str = "personnel",
description: str = "",
data_dir: str | Path | None = None,
) -> dict:
"""Crée un coffre. Sans KEK, la DEK reste None (mode dégradé) : les
métadonnées sont créées, mais aucun secret ne pourra être ajouté tant
que la KEK n'est pas configurée."""
base = resolve_data_dir(data_dir)
slug = valider_slug(slug)
if perimetre not in PERIMETRES:
raise ValidationError(f"Périmètre invalide : {perimetre}")
if not (proprietaire or "").strip():
raise ValidationError("Propriétaire requis")
description = _valider_texte(description, "description")
nom = (nom or "").strip()
if not nom or len(nom) > 128:
raise ValidationError("Nom de coffre requis (128 caractères max)")
with _lock:
data = _load_coffres(base)
if any(c["slug"] == slug for c in data["coffres"]):
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
coffre: dict[str, Any] = {
"id": str(uuid.uuid4()),
"slug": slug,
"nom": nom,
"description": description,
"proprietaire": proprietaire.strip(),
"perimetre": perimetre,
"dek_chiffree": None,
"statut": "actif",
"cree_le": _now_iso(),
}
try:
kek = load_kek()
except Exception:
kek = None
if kek is not None:
coffre["dek_chiffree"] = wrap_dek(generate_dek(), kek, coffre["id"])
data["coffres"].append(coffre)
_save_coffres(base, data)
return coffre
def ensure_dek(coffre: dict, data_dir: str | Path | None = None) -> bytes:
"""Retourne la DEK en clair (mémoire seulement). Génère et persiste la
DEK si le coffre a été créé en mode dégradé et que la KEK est là."""
base = resolve_data_dir(data_dir)
kek = load_kek() # lève KEKAbsenteError si aucune KEK
if coffre.get("dek_chiffree"):
return unwrap_dek(coffre["dek_chiffree"], kek, coffre["id"])
with _lock:
data = _load_coffres(base)
for item in data["coffres"]:
if item["id"] == coffre["id"]:
if item.get("dek_chiffree"):
return unwrap_dek(item["dek_chiffree"], kek, item["id"])
dek = generate_dek()
item["dek_chiffree"] = wrap_dek(dek, kek, item["id"])
_save_coffres(base, data)
return dek
raise IntrouvableError("Coffre introuvable pour génération de DEK")
# Les secrets vivent dans coffres.json (métadonnées), leurs versions dans
# coffres_valeurs.json (chiffrées). Les deux chargements sont faits ici
# pour garder une seule source de vérité dans ce module.
def _load_tout(base: Path) -> tuple[dict, dict]:
data = _load_coffres(base)
if "secrets" not in data:
data["secrets"] = []
return data, _load_valeurs(base)
def compter_secrets(coffre_id: str, data_dir: str | Path | None = None) -> int:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
return sum(1 for s in data["secrets"] if s["coffre_id"] == coffre_id)
def list_secrets(coffre_id: str, data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
return [s for s in data["secrets"] if s["coffre_id"] == coffre_id]
def get_secret(
coffre_id: str, nom: str, data_dir: str | Path | None = None
) -> dict | None:
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
with _lock:
data, _ = _load_tout(base)
for secret in data["secrets"]:
if secret["coffre_id"] == coffre_id and secret["nom"] == nom:
return secret
return None
def secret_metadata(secret: dict, octrois_actifs: int = 0) -> dict:
"""Vue métadonnées seules d'un secret : jamais de valeur, jamais de
texte chiffré, jamais de nonce."""
return {
"id": secret["id"],
"nom": secret["nom"],
"type": secret["type"],
"service": secret.get("service", ""),
"note": secret.get("note", ""),
"expiration": secret.get("expiration"),
"expire": est_expire(secret.get("expiration")),
"version": secret["version_courante"],
"has_value": True,
"cree_le": secret["cree_le"],
"modifie_le": secret["modifie_le"],
"derniere_utilisation": secret.get("derniere_utilisation"),
"octrois_actifs": octrois_actifs,
"reference": f"coffre:{secret['coffre_slug']}/{secret['nom']}",
}
def create_secret(
coffre: dict,
nom: str,
type_secret: str,
valeur: str,
service: str = "",
expiration: str | None = None,
note: str = "",
data_dir: str | Path | None = None,
) -> dict:
"""Chiffre et stocke un secret (nouvelle version 1)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
if type_secret not in TYPES_SECRET:
raise ValidationError(f"Type de secret invalide : {type_secret}")
if not valeur or len(valeur) > MAX_VALEUR:
raise ValidationError("Valeur requise (64 KiB max)")
service = (service or "").strip()
if len(service) > 128:
raise ValidationError("Service trop long (128 caractères max)")
note = _valider_texte(note, "note")
if expiration is not None:
_parse_date(expiration)
dek = ensure_dek(coffre, base) # KEKAbsenteError si pas de KEK
with _lock:
data, valeurs = _load_tout(base)
if any(
s["coffre_id"] == coffre["id"] and s["nom"] == nom
for s in data["secrets"]
):
raise SecretExisteError(f"Secret déjà existant : {nom}")
secret_id = str(uuid.uuid4())
chiffre = encrypt_secret_value(dek, secret_id, 1, valeur)
secret = {
"id": secret_id,
"coffre_id": coffre["id"],
"coffre_slug": coffre["slug"],
"nom": nom,
"type": type_secret,
"service": service,
"note": note,
"expiration": expiration,
"version_courante": 1,
"cree_le": _now_iso(),
"modifie_le": _now_iso(),
"derniere_utilisation": None,
}
data["secrets"].append(secret)
valeurs["versions"][secret_id] = [
{
"numero": 1,
"nonce": chiffre["nonce"],
"ciphertext": chiffre["ciphertext"],
"cree_le": secret["cree_le"],
"remplacee_le": None,
}
]
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret
def rotate_secret(
coffre: dict, nom: str, valeur: str, data_dir: str | Path | None = None
) -> dict:
"""Rotation : nouvelle version active immédiatement, l'ancienne n'est
plus distribuable (seule la version courante est lue à la distribution)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
if not valeur or len(valeur) > MAX_VALEUR:
raise ValidationError("Valeur requise (64 KiB max)")
dek = ensure_dek(coffre, base)
with _lock:
data, valeurs = _load_tout(base)
secret = None
for item in data["secrets"]:
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
secret = item
break
if secret is None:
raise IntrouvableError(f"Secret introuvable : {nom}")
nouvelle_version = secret["version_courante"] + 1
chiffre = encrypt_secret_value(dek, secret["id"], nouvelle_version, valeur)
versions = valeurs["versions"].setdefault(secret["id"], [])
for version in versions:
if version["remplacee_le"] is None:
version["remplacee_le"] = _now_iso()
versions.append(
{
"numero": nouvelle_version,
"nonce": chiffre["nonce"],
"ciphertext": chiffre["ciphertext"],
"cree_le": _now_iso(),
"remplacee_le": None,
}
)
secret["version_courante"] = nouvelle_version
secret["modifie_le"] = _now_iso()
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret
def delete_secret(
coffre: dict, nom: str, data_dir: str | Path | None = None
) -> tuple[dict, list]:
"""Supprime un secret, ses versions et ses octrois (cascade)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
with _lock:
data, valeurs = _load_tout(base)
secret = None
for item in data["secrets"]:
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
secret = item
break
if secret is None:
raise IntrouvableError(f"Secret introuvable : {nom}")
data["secrets"] = [s for s in data["secrets"] if s["id"] != secret["id"]]
valeurs["versions"].pop(secret["id"], None)
octrois_supprimes = [
o for o in data["octrois"] if o.get("secret_id") == secret["id"]
]
data["octrois"] = [
o for o in data["octrois"] if o.get("secret_id") != secret["id"]
]
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret, octrois_supprimes
def distribuer_valeur(
coffre: dict, secret: dict, data_dir: str | Path | None = None
) -> str:
"""Déchiffre la version courante d'un secret (après contrôles d'octroi
faits par le routeur). Refuse un secret expiré (410 côté routeur)."""
base = resolve_data_dir(data_dir)
if est_expire(secret.get("expiration")):
raise SecretExpireError("Secret expiré")
dek = ensure_dek(coffre, base)
with _lock:
_, valeurs = _load_tout(base)
versions = valeurs["versions"].get(secret["id"], [])
courante = None
for version in versions:
if version["numero"] == secret["version_courante"]:
courante = version
break
if courante is None:
raise IntrouvableError("Version courante introuvable")
return decrypt_secret_value(
dek,
secret["id"],
secret["version_courante"],
courante["nonce"],
courante["ciphertext"],
)
def toucher_utilisation(secret_id: str, data_dir: str | Path | None = None) -> None:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
for secret in data["secrets"]:
if secret["id"] == secret_id:
secret["derniere_utilisation"] = _now_iso()
_save_coffres(base, data)
return
def rotate_dek(coffre_slug: str, data_dir: str | Path | None = None) -> dict:
"""Rotation DEK : re-chiffre toutes les versions du coffre avec une
nouvelle DEK, puis remplace la DEK chiffrée du coffre."""
base = resolve_data_dir(data_dir)
coffre = get_coffre_or_404(coffre_slug, base)
kek = load_kek()
ancienne_dek = ensure_dek(coffre, base)
nouvelle_dek = generate_dek()
with _lock:
data, valeurs = _load_tout(base)
secrets_coffre = [s for s in data["secrets"] if s["coffre_id"] == coffre["id"]]
for secret in secrets_coffre:
nouvelles_versions = []
for version in valeurs["versions"].get(secret["id"], []):
nouvelles_versions.append(
{
**reencrypt_secret_value(
ancienne_dek,
nouvelle_dek,
secret["id"],
version["numero"],
version["nonce"],
version["ciphertext"],
),
"numero": version["numero"],
"cree_le": version["cree_le"],
"remplacee_le": version["remplacee_le"],
}
)
valeurs["versions"][secret["id"]] = nouvelles_versions
for item in data["coffres"]:
if item["id"] == coffre["id"]:
item["dek_chiffree"] = wrap_dek(nouvelle_dek, kek, item["id"])
coffre = item
break
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return coffre
def rotate_kek(nouvelle_kek_b64: str, data_dir: str | Path | None = None) -> int:
"""Rotation KEK : re-chiffre uniquement les DEK avec la nouvelle KEK.
L'opérateur doit ensuite remplacer OBSIGATE_KEK / OBSIGATE_KEK_FILE
par la nouvelle valeur (cette fonction ne touche pas à l'environnement).
Retourne le nombre de coffres re-chiffrés."""
base = resolve_data_dir(data_dir)
ancienne_kek = load_kek()
nouvelle_kek = parse_kek(nouvelle_kek_b64)
count = 0
with _lock:
data = _load_coffres(base)
for coffre in data["coffres"]:
if not coffre.get("dek_chiffree"):
continue
dek = unwrap_dek(coffre["dek_chiffree"], ancienne_kek, coffre["id"])
coffre["dek_chiffree"] = wrap_dek(dek, nouvelle_kek, coffre["id"])
count += 1
_save_coffres(base, data)
return count
# ---------------------------------------------------------------------------
# Consommateurs (jetons serveur-à-serveur, hachés, révocables par jti)
# ---------------------------------------------------------------------------
def _hash_jeton(jeton: str) -> str:
return hashlib.sha256(jeton.encode("utf-8")).hexdigest()
def list_consommateurs(data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return [
{k: v for k, v in c.items() if k != "jeton_hash"}
for c in _load_coffres(base)["consommateurs"]
]
def create_consommateur(
nom: str, cree_par: str, data_dir: str | Path | None = None
) -> tuple[dict, str]:
"""Crée un consommateur et retourne (métadonnées, jeton à usage unique).
Le jeton n'est JAMAIS persisté : seul son hash SHA-256 est stocké."""
base = resolve_data_dir(data_dir)
nom = (nom or "").strip()
if not nom or len(nom) > 128:
raise ValidationError("Nom de consommateur requis (128 caractères max)")
jeton = "csk_" + _secrets.token_urlsafe(32)
with _lock:
data = _load_coffres(base)
consommateur = {
"id": str(uuid.uuid4()),
"nom": nom,
"jeton_hash": _hash_jeton(jeton),
"jeton_prefixe": jeton[:12],
"statut": "actif",
"cree_par": cree_par,
"cree_le": _now_iso(),
}
data["consommateurs"].append(consommateur)
_save_coffres(base, data)
public = {k: v for k, v in consommateur.items() if k != "jeton_hash"}
return public, jeton
def verifier_jeton_consommateur(
jeton: str, data_dir: str | Path | None = None
) -> dict | None:
"""Retourne le consommateur (sans hash) si le jeton correspond, sinon None."""
if not jeton:
return None
base = resolve_data_dir(data_dir)
cible = _hash_jeton(jeton)
with _lock:
for consommateur in _load_coffres(base)["consommateurs"]:
if hmac.compare_digest(consommateur.get("jeton_hash", ""), cible):
return {
k: v for k, v in consommateur.items() if k != "jeton_hash"
}
return None
def revoquer_consommateur(
consommateur_id: str, data_dir: str | Path | None = None
) -> dict:
base = resolve_data_dir(data_dir)
with _lock:
data = _load_coffres(base)
for consommateur in data["consommateurs"]:
if consommateur["id"] == consommateur_id:
consommateur["statut"] = "revoque"
_save_coffres(base, data)
return {
k: v
for k, v in consommateur.items()
if k != "jeton_hash"
}
raise IntrouvableError("Consommateur introuvable")
# ---------------------------------------------------------------------------
# Octrois
# ---------------------------------------------------------------------------
def list_octrois(coffre_id: str, data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return [
o for o in _load_coffres(base)["octrois"] if o["coffre_id"] == coffre_id
]
def compter_octrois_actifs(
secret_id: str, data_dir: str | Path | None = None
) -> int:
base = resolve_data_dir(data_dir)
with _lock:
return sum(
1
for o in _load_coffres(base)["octrois"]
if o.get("secret_id") == secret_id
and o["statut"] == "actif"
and not est_expire(o.get("expiration"))
)
def create_octroi(
coffre: dict,
cible_type: str,
cible_id: str,
secret_id: str | None,
cree_par: str,
expiration: str | None = None,
data_dir: str | Path | None = None,
) -> dict:
base = resolve_data_dir(data_dir)
if cible_type not in CIBLES_OCTROI:
raise ValidationError(f"Type de cible invalide : {cible_type}")
if not (cible_id or "").strip():
raise ValidationError("Cible d'octroi requise")
if secret_id is not None:
with _lock:
data = _load_coffres(base)
if not any(
s["id"] == secret_id and s["coffre_id"] == coffre["id"]
for s in data["secrets"]
):
raise IntrouvableError("Secret introuvable dans ce coffre")
if expiration is None:
expiration = (datetime.now(UTC) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
else:
_parse_date(expiration)
with _lock:
data = _load_coffres(base)
octroi = {
"id": str(uuid.uuid4()),
"coffre_id": coffre["id"],
"secret_id": secret_id,
"cible_type": cible_type,
"cible_id": cible_id.strip(),
"expiration": expiration,
"statut": "actif",
"cree_par": cree_par,
"cree_le": _now_iso(),
"revoque_le": None,
}
data["octrois"].append(octroi)
_save_coffres(base, data)
return octroi
def revoquer_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict:
"""Révocation : statut -> revoque (conservé pour distinguer
octroi_revoque de octroi_absent à la distribution)."""
base = resolve_data_dir(data_dir)
with _lock:
data = _load_coffres(base)
for octroi in data["octrois"]:
if octroi["id"] == octroi_id:
octroi["statut"] = "revoque"
octroi["revoque_le"] = _now_iso()
_save_coffres(base, data)
return octroi
raise IntrouvableError("Octroi introuvable")
def statut_octroi_consommateur(
consommateur_id: str,
coffre_id: str,
secret_id: str,
data_dir: str | Path | None = None,
) -> tuple[str, dict | None]:
"""Statut d'accès d'un consommateur à un secret.
Retourne ("ok" | "revoque" | "expire" | "absent", octroi). Un octroi
coffre-wide (secret_id None) couvre tous les secrets du coffre.
Priorité : ok > revoque > expire > absent (un octroi révoqué ne doit
jamais être masqué par l'absence d'un autre octroi)."""
base = resolve_data_dir(data_dir)
with _lock:
candidats = [
o
for o in _load_coffres(base)["octrois"]
if o["cible_type"] == "consommateur"
and o["cible_id"] == consommateur_id
and o["coffre_id"] == coffre_id
and (o.get("secret_id") is None or o["secret_id"] == secret_id)
]
for octroi in candidats:
if octroi["statut"] == "actif" and not est_expire(octroi.get("expiration")):
return "ok", octroi
for octroi in candidats:
if octroi["statut"] == "revoque":
return "revoque", octroi
for octroi in candidats:
if octroi["statut"] == "actif" and est_expire(octroi.get("expiration")):
return "expire", octroi
return "absent", None
def secrets_accessibles_utilisateur(
username: str, coffre: dict, data_dir: str | Path | None = None
) -> set | None:
"""Secrets d'un coffre visibles par un utilisateur via octroi.
Retourne None si un octroi coffre-wide actif existe (tout le coffre),
sinon l'ensemble des ids de secrets octroyés, sinon l'ensemble vide."""
base = resolve_data_dir(data_dir)
with _lock:
octrois = [
o
for o in _load_coffres(base)["octrois"]
if o["cible_type"] == "utilisateur"
and o["cible_id"] == username
and o["coffre_id"] == coffre["id"]
and o["statut"] == "actif"
and not est_expire(o.get("expiration"))
]
if any(o.get("secret_id") is None for o in octrois):
return None
return {o["secret_id"] for o in octrois if o.get("secret_id")}
+197
View File
@@ -0,0 +1,197 @@
"""URLs publiques de l'écosystème dracodev — par configuration (#223).
Règle dev/prod (Bruno, 2026-10-25) : **aucun nom public de
l'écosystème n'est figé dans le code vivant**. Chaque URL publique
vient de l'environnement ; les valeurs par défaut sont les noms
**officiels** (sans suffixe) de la future production. L'environnement
dev déclare ses noms en ``-dev`` dans son seul ``.env`` — le code
n'en connaît aucun.
Variables (voir ``.env.example``) :
- ``DRACO_AGENT_URL`` — base publique du service d'agents ;
à défaut, l'origine est **dérivée** de ``AGENT_EVENEMENTS_URL``
(la configuration réelle de l'émission d'événements, #220), puis
le défaut officiel.
- ``DRACO_OBSIGATE_URL`` — base publique d'ObsiGate (liens profonds).
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes
Kanbante *sont* des issues Gitea, contrat liens-profonds) ;
distincte de ``OBSIGATE_GITEA_URL`` (source connectée de
l'assistant, réglable depuis l'UI, pas une URL d'affichage).
Résolution d'une URL sœur (BUG-120) : la variable explicite prime ;
à défaut, l'**environnement est dérivé de l'hôte de l'agent** tel
qu'ObsiGate le connaît déjà (:func:`agent_url`) — si cet hôte est de
la forme ``agent<SUFFIXE>.dracodev.net`` (``-dev`` en dev, vide en
prod), l'URL sœur est ``https://<slug><SUFFIXE>.dracodev.net``. Un
hôte hors motif (autre domaine, LAN, IP, port) ne dérive **rien** :
on ne fabrique jamais un lien deviné, le défaut officiel s'applique.
L'installation pointe donc les bons liens selon son environnement
sans déclarer les quatre ``DRACO_<APP>_URL``.
Ce module ne lit l'environnement qu'à l'appel (jamais à l'import) :
les tests et un changement de ``.env`` suivi d'un redémarrage
prennent effet sans état figé.
"""
from __future__ import annotations
import os
import re
from urllib.parse import urlparse
# Défauts officiels (production future) — jamais de nom en ``-dev`` ici.
DEFAUTS: dict[str, str] = {
"agent": "https://agent.dracodev.net",
"obsigate": "https://og.dracodev.net",
"flowdeck": "https://flowdeck.dracodev.net",
"tiktak": "https://tiktak.dracodev.net",
"kanbante": "https://kanbante.dracodev.net",
"gitea": "https://git.dracodev.net",
}
_VARIABLES: dict[str, str] = {
"agent": "DRACO_AGENT_URL",
"obsigate": "DRACO_OBSIGATE_URL",
"flowdeck": "DRACO_FLOWDECK_URL",
"tiktak": "DRACO_TIKTAK_URL",
"kanbante": "DRACO_KANBANTE_URL",
"gitea": "DRACO_GITEA_URL",
}
# Slugs d'hôte des applications sœurs pour la dérivation
# d'environnement (BUG-120) : premier label de ``<slug>.dracodev.net``
# (et de ``<slug>-dev.dracodev.net`` en dev). Gitea est **exclue** :
# son URL ne se dérive jamais.
_SLUGS_HOTE: dict[str, str] = {
"obsigate": "og",
"flowdeck": "flowdeck",
"tiktak": "tiktak",
"kanbante": "kanbante",
}
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
# d'environnement éventuel (``-dev`` en dev, vide en prod).
_MOTIF_AGENT = re.compile(r"^agent(-[a-z0-9-]*)?$")
def _nettoyer(valeur: str) -> str:
return valeur.strip().rstrip("/")
def origine(url: str) -> str:
"""Origine ``schéma://hôte[:port]`` d'une URL (``""`` si illisible)."""
u = urlparse(url or "")
if u.scheme and u.netloc:
return f"{u.scheme}://{u.netloc}"
return ""
def agent_url() -> str:
"""Base publique du service d'agents.
Priorité : ``DRACO_AGENT_URL``, puis l'origine de
``AGENT_EVENEMENTS_URL`` (déjà la configuration réelle du poste),
puis le défaut officiel.
"""
explicite = _nettoyer(os.environ.get(_VARIABLES["agent"], ""))
if explicite:
return explicite
evenements = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", ""))
if evenements:
derivee = origine(evenements)
if derivee:
return derivee
return DEFAUTS["agent"]
def agent_origine() -> str:
"""Origine du service d'agents, pour la CSP (``connect-src``/``frame-src``)."""
return origine(agent_url()) or DEFAUTS["agent"]
def _suffixe_environnement() -> str | None:
"""Suffixe d'environnement déduit de l'hôte de l'agent (BUG-120).
Lit l'hôte de :func:`agent_url` — l'origine de l'agent telle
qu'ObsiGate la connaît déjà (``DRACO_AGENT_URL``, à défaut
``AGENT_EVENEMENTS_URL``). Si cet hôte est exactement de la forme
``agent<SUFFIXE>.dracodev.net`` sans port, retourne ``SUFFIXE``
(``-dev`` en dev, ``""`` en prod). Sinon (autre domaine, LAN, IP,
port exotique) retourne ``None`` : aucune dérivation, on ne
fabrique jamais un lien deviné.
"""
u = urlparse(agent_url())
hote = (u.hostname or "").lower()
labels = hote.split(".")
if len(labels) != 3 or labels[1:] != ["dracodev", "net"]:
return None
try:
if u.port is not None:
return None
except ValueError:
return None
motif = _MOTIF_AGENT.match(labels[0])
if motif is None:
return None
return motif.group(1) or ""
def app_url(app: str) -> str:
"""Base publique d'une application de l'écosystème (sans ``/`` final).
Résolution d'une URL sœur (BUG-120) : ``DRACO_<APP>_URL``
explicite d'abord ; à défaut, dérivation d'environnement depuis
l'hôte de l'agent (:func:`_suffixe_environnement`) ; à défaut,
le défaut officiel. L'URL Gitea ne se dérive jamais.
"""
if app == "agent":
return agent_url()
variable = _VARIABLES[app]
explicite = _nettoyer(os.environ.get(variable, ""))
if explicite:
return explicite
if app in _SLUGS_HOTE:
suffixe = _suffixe_environnement()
if suffixe is not None:
return f"https://{_SLUGS_HOTE[app]}{suffixe}.dracodev.net"
return DEFAUTS[app]
def app_hote(app: str) -> str:
"""Nom d'hôte (minuscules) de l'URL publique configurée de *app*."""
return (urlparse(app_url(app)).hostname or "").lower()
def hotes_reconnus() -> dict[str, str]:
"""``{hôte: app}`` pour le parseur de liens profonds.
Contient **toujours** les hôtes officiels (un lien collé depuis la
production reste reconnaissable en dev, et réciproquement) **plus**
l'hôte résolu de chaque application (les noms en ``-dev`` du poste,
déclarés dans son ``.env`` ou dérivés de l'hôte de l'agent).
"""
hotes: dict[str, str] = {}
for app, defaut in DEFAUTS.items():
hote = (urlparse(defaut).hostname or "").lower()
if hote:
hotes[hote] = app
for app in DEFAUTS:
hote = app_hote(app)
if hote:
hotes[hote] = app
return hotes
def urls_publiques() -> dict[str, str]:
"""Carte des URL publiques injectée à l'hôte web (``window.DRACO_URLS``).
C'est le contrat d'injection de dracodev-commons : l'application
hôte (ici ObsiGate, qui sert ``index.html``) fournit les URL aux
assets partagés (panneau Spark, barre, liens) — les assets ne
portent que les défauts officiels en repli.
"""
return {app: app_url(app) for app in DEFAUTS}
+2 -2
View File
@@ -359,7 +359,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
rendered = _render_body(body, path.parent, vault_path, path)
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.UTC).timestamp())}"
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
@@ -383,7 +383,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
<dc:title>{html_mod.escape(title)}</dc:title>
<dc:creator>{html_mod.escape(author)}</dc:creator>
<dc:language>fr</dc:language>
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
<meta property="dcterms:modified">{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
+76 -10
View File
@@ -24,7 +24,7 @@ import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
from urllib.parse import parse_qs, quote, unquote, urlparse
logger = logging.getLogger("obsigate.external_refs")
@@ -64,26 +64,87 @@ def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
# ------------------------------------------------------------ parseur d'URL
def build_deep_link(target_app: str, target_type: str, target_id: str) -> str | None:
"""Construit l'URL profonde d'affichage depuis la **configuration**
courante (#223), ou ``None`` si la forme est inconnue.
Les références stockées portent une URL absolue figée au moment du
collage (voir ``add_ref``) : elle peut nommer l'autre environnement
(un lien collé en prod, lu en dev). L'affichage ne l'utilise donc
jamais telle quelle — il reconstruit l'URL depuis
``(target_app, target_type, target_id)`` et les bases publiques
configurées (``backend.draco_config``), au moment de la lecture.
Aucune donnée stockée n'est migrée ni réécrite.
"""
from backend.draco_config import app_url
if target_app == "tiktak":
if target_type == "task":
return f"{app_url('tiktak')}/?task={quote(target_id, safe='')}"
if target_type == "list":
return f"{app_url('tiktak')}/?list={quote(target_id, safe='')}"
return None
if target_app == "obsigate" and target_type == "note":
return f"{app_url('obsigate')}/#file={quote(target_id, safe=':')}"
if target_app == "flowdeck":
if target_type == "page":
return f"{app_url('flowdeck')}/pages/{quote(target_id, safe='')}"
if target_type == "project":
return f"{app_url('flowdeck')}/collections/{quote(target_id, safe='')}"
return None
if target_app == "kanbante" and target_type == "card":
m = re.fullmatch(r"([^/]+)/([^/#]+)#(\d+)", target_id or "")
if m:
owner, repo, num = m.groups()
return f"{app_url('gitea')}/{owner}/{repo}/issues/{num}"
return None
return None
def _avec_url_affichee(ref: dict[str, Any]) -> dict[str, Any]:
"""Copie de la référence dont ``target_url`` est reconstruite
depuis la config courante (#223) — le stockage n'est pas touché."""
construite = build_deep_link(
str(ref.get("target_app") or ""),
str(ref.get("target_type") or ""),
str(ref.get("target_id") or ""),
)
if construite is None:
return ref
copie = dict(ref)
copie["target_url"] = construite
return copie
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
"""Reconnaît une URL profonde des applications dracodev.
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
Hôtes reconnus (#223) : les hôtes **officiels** et les hôtes
**configurés** de chaque application (``backend.draco_config``) —
un lien collé depuis l'environnement courant (noms en ``-dev``
déclarés dans son ``.env``) comme depuis l'autre environnement
est donc reconnu ; aucun nom d'environnement n'est figé ici.
"""
from backend.draco_config import hotes_reconnus
try:
u = urlparse((url or "").strip())
except ValueError:
return None
host = (u.hostname or "").lower()
if host == "tiktak.dracodev.net":
app = hotes_reconnus().get(host)
if app == "tiktak":
q = parse_qs(u.query)
if q.get("task"):
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
if q.get("list"):
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
return None
if host == "og.dracodev.net":
if app == "obsigate":
frag = u.fragment or ""
if frag.startswith("file="):
cible = unquote(frag[5:])
@@ -91,7 +152,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
nom = cible.rsplit("/", 1)[-1]
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
return None
if host == "flowdeck.dracodev.net":
if app == "flowdeck":
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
if m:
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
@@ -99,7 +160,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
if m:
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
return None
if host == "git.dracodev.net":
if app == "gitea":
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
if m:
owner, repo, num = m.groups()
@@ -111,20 +172,25 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
# ------------------------------------------------------------ CRUD
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
"""Références posées sur une note, dans l'ordre de création."""
"""Références posées sur une note, dans l'ordre de création.
``target_url`` de chaque référence retournée est **reconstruite
depuis la config courante** (#223, ``_avec_url_affichee``) : la
valeur stockée, figée au collage, n'est jamais servie telle quelle.
"""
if not username:
return []
return [
r for r in _read_refs(username)
_avec_url_affichee(r) for r in _read_refs(username)
if r.get("owner_vault") == vault and r.get("owner_path") == path
]
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Une référence par son identifiant, ou ``None``."""
"""Une référence par son identifiant, ou ``None`` (URL reconstruite, #223)."""
for r in _read_refs(username):
if r.get("id") == ref_id:
return r
return _avec_url_affichee(r)
return None
@@ -169,4 +235,4 @@ def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
return None
removed = next(r for r in refs if r.get("id") == ref_id)
_write_refs(username, kept)
return removed
return _avec_url_affichee(removed)
+1 -1
View File
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
template = loc.get("guide105.export_footer", "")
if "%s" not in template and "{" not in template:
template = "ObsiGate {version}"
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
return _collapse(template).format(version=_app_version(), date=today)
+4 -4
View File
@@ -5,7 +5,7 @@ import os
import re
import threading
from collections.abc import Callable
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -581,7 +581,7 @@ def _scan_vault(
try:
relative = fpath.relative_to(vault_root)
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
# #86 differential scan: reuse the previous entry when neither
# size nor mtime changed — skips the disk read + parse below.
@@ -866,7 +866,7 @@ async def build_index(progress_callback=None) -> None:
# Record timestamp of full index rebuild
global _last_full_index_ts
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
_last_full_index_ts = datetime.now(UTC).isoformat()
# Build attachment index
from backend.attachment_indexer import build_attachment_index
@@ -1033,7 +1033,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
return None
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
tags: list[str] = []
+41 -3
View File
@@ -186,15 +186,21 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
# `style=` et `el.style` omniprésents — chantier séparé).
# #223 : l'origine du service d'agents n'est pas figée — elle
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
from backend.draco_config import agent_origine
agent = agent_origine()
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
f"frame-src 'self' blob: {agent}; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
@@ -524,6 +530,7 @@ from backend.auth.middleware import (
from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_router
from backend.routers.backups import router as backups_router
from backend.routers.coffres import router as coffres_router
from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
@@ -558,6 +565,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
app.include_router(config_router) # ROADMAP #85 T7 — Config
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
@@ -829,6 +837,36 @@ def _html_with_nonce(request: Request, name: str) -> str:
request.state.csp_nonce,
)
def _index_html(request: Request) -> str:
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
Contrat d'injection de dracodev-commons : l'hôte (ObsiGate, qui
sert la page) fournit les URL aux assets partagés — panneau Spark,
barre d'apps, liens inter-apps lisent ``window.DRACO_URLS`` et ne
portent que les défauts officiels en repli. Les valeurs viennent
de :mod:`backend.draco_config` (environnement ; défauts officiels,
noms en ``-dev`` déclarés par le seul ``.env`` du poste). Le script
injecté passe par le même mécanisme de nonce CSP que les autres
scripts inline de la page.
"""
import json as _json
from backend.csp import inject_csp_nonce
from backend.draco_config import urls_publiques
html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
script = (
"<script>window.DRACO_URLS = "
+ _json.dumps(urls_publiques()).replace("<", "\\u003c")
+ ";</script>"
)
if "</head>" in html:
html = html.replace("</head>", script + "</head>", 1)
else: # repli défensif : tout en tête de document
html = script + html
return inject_csp_nonce(html, request.state.csp_nonce)
if FRONTEND_DIR.exists():
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@@ -907,5 +945,5 @@ if FRONTEND_DIR.exists():
"""Serve the SPA index.html for all non-API routes."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")
+4 -4
View File
@@ -25,7 +25,7 @@ import os
import smtplib
import threading
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from email.message import EmailMessage
from pathlib import Path
from typing import Any
@@ -182,7 +182,7 @@ def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict
"type": channel_type,
"enabled": True,
"config": normalized,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
"last_sent_at": None,
"last_error": None,
}
@@ -287,7 +287,7 @@ def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
"event": trigger,
"title": title,
"message": message,
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"source": "obsigate-notify",
},
timeout=10.0,
@@ -345,6 +345,6 @@ def _mark_sent(channel_id: str, error: str | None) -> None:
channels = _read_channels()
for channel in channels:
if channel.get("id") == channel_id:
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
channel["last_sent_at"] = datetime.now(UTC).isoformat()
channel["last_error"] = error
_write_channels(channels)
+2 -2
View File
@@ -6,7 +6,7 @@ import base64
import json
import logging
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -185,7 +185,7 @@ async def subscribe_push(
"keys": request.subscription.keys,
"vault": request.vault,
"username": username,
"created_at": datetime.now(timezone.utc).isoformat()
"created_at": datetime.now(UTC).isoformat()
}
_push_subscriptions.append(subscription)
+31 -7
View File
@@ -16,13 +16,16 @@ sortedcontainers>=2.4.0
snowballstemmer>=2.2.0
weasyprint>=70.0
httpx>=0.27.0
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
# et n'est jamais mis à niveau.
pypdf>=6.16.1
# Plancher de sécurité (BUG-093, relevé par #224) : 6.16.0 est vulnérable à
# deux DoS de ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm,
# fix 6.16.1), atteignables via backend/pdf_reader.py (PDF fournis par
# l'utilisateur). #224 : la version préinstallée dans la toolcache du
# runner (6.19.0) est frappée par 11 CVE supplémentaires (dont
# CVE-2026-113786, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580),
# corrigées au plus tard en 6.21.2 — le plancher doit être >= 6.21.2 :
# en dessous, pip répond « already satisfied » pour la toolcache et
# n'aligne jamais (même piège que BUG-093).
pypdf>=6.21.2
pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0
@@ -55,3 +58,24 @@ multidict>=6.9.1
# transitive (httpx/requests) — sans plancher explicite, pip-audit
# signale la version héritée de l'environnement.
certifi>=2026.10.19
# Planchers de sécurité (#224) : paquets transitifs ou préinstallés dans
# la toolcache du runner, audités par pip-audit (CI, bloquant) mais jamais
# alignés sans plancher explicite — « already satisfied » (même piège que
# BUG-093). Versions relevées par le job security du run sur 2.78.0 :
# brotli 1.2.0 (transitive fonttools[woff] via weasyprint) — PYSEC-2026-4322
# et GHSA-9v8h-qqgm-4m35, corrigées en 1.2.1.
brotli>=1.2.1
# filelock 3.32.3 (transitive de CacheControl, outil pip-audit, et
# préinstallée dans la toolcache) — CVE-2026-112940, CVE-2026-112913 et
# CVE-2026-112907, corrigées en 4.2.1.
filelock>=4.2.1
# opentelemetry-api 1.37.0 (préinstallée dans la toolcache du runner,
# aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée
# en 1.45.1.
opentelemetry-api>=1.45.1
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
cryptography>=44.0
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
msgpack>=1.2.3
+2 -2
View File
@@ -19,7 +19,7 @@ Adaptations strictement équivalentes :
import logging
import os
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -207,7 +207,7 @@ async def api_backups_list(
"file": rel_file,
"backup_file": fpath.name,
"timestamp": ts,
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
"size": fsize,
"full_path": str(fpath),
})
+606
View File
@@ -0,0 +1,606 @@
"""API du coffre Secrets ObsiGate (Phase 1b, spec §7, §10, §11).
Monté dans ``backend/main.py`` à côté de ``config_router`` — JAMAIS dans
``backend/routers/vaults.py`` : un coffre n'est pas un vault de notes, il
ne passe ni par l'indexeur, ni par la recherche, ni par le graphe, et
aucun outil MCP de secret n'existe.
Règles d'or, appliquées côté serveur à chaque requête :
- Toutes les lectures renvoient des métadonnées seules (``has_value``,
version, dates, octrois actifs) : jamais de valeur, jamais de texte
chiffré, jamais de nonce.
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
un consommateur authentifié par jeton (hash + jti, jamais la session
utilisateur), avec un octroi actif et un contexte obligatoire.
- Autorisations (spec §6) : le propriétaire seul sur son coffre
personnel ; l'admin gère les coffres d'instance, les consommateurs et
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
ne donne AUCUN droit sur un coffre (``check_vault_access`` n'est pas
utilisé ici, volontairement).
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
qu'il n'est pas activé, toutes les routes répondent 503.
"""
from __future__ import annotations
import logging
import os
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from backend.audit import get_recent_entries, log_coffre_event
from backend.auth.middleware import require_admin, require_auth
from backend.coffre_crypto import (
CryptoIndisponibleError,
KEKAbsenteError,
generate_kek_b64,
)
from backend.coffre_store import (
CoffreExisteError,
IntrouvableError,
SecretExisteError,
SecretExpireError,
ValidationError,
compter_octrois_actifs,
compter_secrets,
create_coffre,
create_consommateur,
create_octroi,
create_secret,
delete_secret,
distribuer_valeur,
get_coffre,
get_coffre_by_id,
get_coffre_or_404,
get_octroi,
get_secret,
list_coffres,
list_consommateurs,
list_octrois,
list_secrets,
revoquer_consommateur,
revoquer_octroi,
rotate_dek,
rotate_kek,
rotate_secret,
secret_metadata,
secrets_accessibles_utilisateur,
statut_octroi_consommateur,
toucher_utilisation,
verifier_jeton_consommateur,
)
logger = logging.getLogger("obsigate.coffres")
router = APIRouter(tags=["Coffres Secrets"])
CODES_ERREUR = {
"octroi_absent",
"octroi_revoque",
"octroi_expire",
"secret_expire",
"kek_absente",
"contexte_manquant",
"consommateur_invalide",
"consommateur_revoque",
"crypto_indisponible",
"secrets_desactives",
}
def secrets_actives() -> bool:
"""Feature flag : défaut désactivé, activation explicite par l'opérateur."""
return os.environ.get("OBSIGATE_SECRETS_ENABLED", "false").lower() in (
"1",
"true",
"yes",
"on",
)
def _exiger_actives() -> None:
if not secrets_actives():
raise HTTPException(status_code=503, detail="secrets_desactives")
def _username(user: dict) -> str:
return str(user.get("username", ""))
def _est_admin(user: dict) -> bool:
return user.get("role") == "admin"
def _peut_voir_coffre(user: dict, coffre: dict) -> bool:
"""Voir les métadonnées d'un coffre."""
if coffre["proprietaire"] == _username(user):
return True
if _est_admin(user) and coffre["perimetre"] == "instance":
return True
# Octroi utilisateur (coffre-wide ou par secret) : accès en lecture.
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return True
return len(accessibles) > 0
def _peut_gerer_coffre(user: dict, coffre: dict) -> bool:
"""Créer / roter / supprimer des secrets et gérer les octrois."""
if coffre["proprietaire"] == _username(user):
return True
return _est_admin(user) and coffre["perimetre"] == "instance"
def _exiger_voir(user: dict, coffre: dict) -> None:
if not _peut_voir_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _exiger_gerer(user: dict, coffre: dict) -> None:
if not _peut_gerer_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _coffre_or_404(slug: str) -> dict:
try:
return get_coffre_or_404(slug)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _secret_or_404(coffre: dict, nom: str) -> dict:
try:
secret = get_secret(coffre["id"], nom)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if secret is None:
raise HTTPException(status_code=404, detail=f"Secret introuvable : {nom}")
return secret
def _metadata_coffre(coffre: dict) -> dict:
return {
"slug": coffre["slug"],
"nom": coffre["nom"],
"description": coffre.get("description", ""),
"proprietaire": coffre["proprietaire"],
"perimetre": coffre["perimetre"],
"statut": coffre["statut"],
"kek_configuree": coffre.get("dek_chiffree") is not None,
"nb_secrets": compter_secrets(coffre["id"]),
"cree_le": coffre["cree_le"],
}
def _metadata_secret(coffre: dict, secret: dict) -> dict:
return secret_metadata(secret, compter_octrois_actifs(secret["id"]))
def _secrets_visibles(user: dict, coffre: dict) -> list:
"""Secrets visibles par l'utilisateur (métadonnées seules)."""
if coffre["proprietaire"] == _username(user) or (
_est_admin(user) and coffre["perimetre"] == "instance"
):
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
return [
_metadata_secret(coffre, s)
for s in list_secrets(coffre["id"])
if s["id"] in accessibles
]
def _audit(user: str, action: str, **kwargs) -> None:
try:
log_coffre_event(user, action, **kwargs)
except Exception as exc: # l'audit ne doit jamais casser l'opération
logger.warning("Échec audit coffre (%s) : %s", action, exc)
# ---------------------------------------------------------------------------
# Coffres
# ---------------------------------------------------------------------------
@router.get("/api/coffres")
async def api_list_coffres(current_user=Depends(require_auth)):
_exiger_actives()
visibles = [c for c in list_coffres() if _peut_voir_coffre(current_user, c)]
return [_metadata_coffre(c) for c in visibles]
@router.post("/api/coffres")
async def api_create_coffre(body: dict = Body(...), current_user=Depends(require_auth)):
_exiger_actives()
slug = body.get("slug", "")
perimetre = body.get("perimetre", "personnel")
if perimetre == "instance" and not _est_admin(current_user):
raise HTTPException(
status_code=403, detail="Seul un admin peut créer un coffre d'instance"
)
try:
coffre = create_coffre(
slug=slug,
nom=body.get("nom", ""),
proprietaire=_username(current_user),
perimetre=perimetre,
description=body.get("description", ""),
)
except CoffreExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"coffre_cree",
coffre=coffre["slug"],
resultat="ok",
)
return _metadata_coffre(coffre)
# ---------------------------------------------------------------------------
# Consommateurs (admin seulement, jeton rendu une seule fois)
# ---------------------------------------------------------------------------
@router.get("/api/coffres/consommateurs")
async def api_list_consommateurs(current_user=Depends(require_admin)):
_exiger_actives()
return list_consommateurs()
@router.post("/api/coffres/consommateurs")
async def api_create_consommateur(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur, jeton = create_consommateur(
nom=body.get("nom", ""), cree_par=_username(current_user)
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
# Le jeton n'est rendu qu'ici, jamais stocké en clair, jamais relisté.
return {**consommateur, "jeton": jeton}
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
async def api_revoke_consommateur(
consommateur_id: str, current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur = revoquer_consommateur(consommateur_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_revoque",
consommateur=consommateur["nom"],
resultat="ok",
)
return consommateur
# ---------------------------------------------------------------------------
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/distribuer")
async def api_distribuer(request: Request, body: dict = Body(...)):
_exiger_actives()
reference = (body.get("reference") or "").strip()
contexte_type = (body.get("contexte_type") or "").strip()
contexte_id = (body.get("contexte_id") or "").strip()
if not contexte_type or not contexte_id:
raise HTTPException(status_code=400, detail="contexte_manquant")
if not reference.startswith("coffre:") or "/" not in reference[7:]:
raise HTTPException(status_code=400, detail="reference_invalide")
slug, nom = reference[7:].split("/", 1)
# Authentification consommateur : Bearer, hash + statut, jamais un JWT
# de session utilisateur.
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
_audit("consommateur", "distribution_refusee", secret_ref=reference,
resultat="consommateur_invalide", contexte_id=contexte_id)
raise HTTPException(status_code=401, detail="consommateur_invalide")
if consommateur["statut"] != "actif":
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="consommateur_revoque", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="consommateur_revoque")
try:
coffre = get_coffre(slug)
secret = get_secret(coffre["id"], nom) if coffre else None
except ValidationError:
raise HTTPException(status_code=400, detail="reference_invalide") from None
if coffre is None or secret is None or coffre["statut"] != "actif":
# Anti-énumération : un secret inexistant se comporte comme un
# octroi absent, il ne révèle pas l'existence du coffre/secret.
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="octroi_absent", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="octroi_absent")
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"]
)
if statut != "ok":
code = {
"revoque": "octroi_revoque",
"expire": "octroi_expire",
"absent": "octroi_absent",
}[statut]
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat=code, contexte_id=contexte_id)
raise HTTPException(status_code=403, detail=code)
try:
valeur = distribuer_valeur(coffre, secret)
except SecretExpireError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="secret_expire", contexte_id=contexte_id)
raise HTTPException(status_code=410, detail="secret_expire") from None
except KEKAbsenteError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="kek_absente", contexte_id=contexte_id)
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
toucher_utilisation(secret["id"])
_audit("consommateur", "distribution_ok", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="ok", contexte_id=contexte_id)
return {
"reference": reference,
"valeur": valeur,
"version": secret["version_courante"],
}
# ---------------------------------------------------------------------------
# Un coffre, ses secrets, ses octrois, son journal
# ---------------------------------------------------------------------------
@router.get("/api/coffres/{slug}")
async def api_get_coffre(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _metadata_coffre(coffre)
@router.get("/api/coffres/{slug}/secrets")
async def api_list_secrets(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _secrets_visibles(current_user, coffre)
@router.post("/api/coffres/{slug}/secrets")
async def api_create_secret(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = create_secret(
coffre=coffre,
nom=body.get("nom", ""),
type_secret=body.get("type", "autre"),
valeur=body.get("valeur", ""),
service=body.get("service", ""),
expiration=body.get("expiration"),
note=body.get("note", ""),
)
except SecretExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_cree",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.post("/api/coffres/{slug}/secrets/{nom}/rotation")
async def api_rotate_secret(
slug: str, nom: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = rotate_secret(coffre, nom, body.get("valeur", ""))
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_rotation",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.delete("/api/coffres/{slug}/secrets/{nom}")
async def api_delete_secret(
slug: str, nom: str, current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret, _octrois = delete_secret(coffre, nom)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"secret_supprime",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return {"status": "deleted", "nom": secret["nom"]}
@router.get("/api/coffres/{slug}/octrois")
async def api_list_octrois(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return list_octrois(coffre["id"])
@router.post("/api/coffres/{slug}/octrois")
async def api_create_octroi(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
secret_id = None
if body.get("secret_nom"):
secret = _secret_or_404(coffre, body["secret_nom"])
secret_id = secret["id"]
try:
octroi = create_octroi(
coffre=coffre,
cible_type=body.get("cible_type", ""),
cible_id=body.get("cible_id", ""),
secret_id=secret_id,
cree_par=_username(current_user),
expiration=body.get("expiration"),
)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre["slug"],
secret_ref=(
f"coffre:{coffre['slug']}/{body.get('secret_nom', '*')}"
),
resultat="ok",
)
return octroi
@router.delete("/api/coffres/octrois/{octroi_id}")
async def api_revoke_octroi(octroi_id: str, current_user=Depends(require_auth)):
_exiger_actives()
# Le gestionnaire du coffre concerné (ou l'admin d'instance) seul peut
# révoquer : on résout le coffre depuis l'octroi avant toute action.
octroi = get_octroi(octroi_id)
if octroi is None:
raise HTTPException(status_code=404, detail="Octroi introuvable")
coffre = get_coffre_by_id(octroi["coffre_id"])
if coffre is None:
raise HTTPException(status_code=404, detail="Coffre introuvable")
_exiger_gerer(current_user, coffre)
try:
octroi = revoquer_octroi(octroi_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_revoque",
coffre=coffre["slug"],
resultat="ok",
)
return octroi
@router.get("/api/coffres/{slug}/journal")
async def api_journal(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
# Défense en profondeur : même si une entrée contenait un jour un champ
# sensible, il ne sort jamais par cette route.
nettoyees = []
for entry in get_recent_entries(limit=100):
if entry.get("coffre") != slug:
continue
nettoyees.append(
{
k: v
for k, v in entry.items()
if k not in ("valeur", "ciphertext", "nonce", "jeton", "dek_chiffree")
}
)
return nettoyees
# ---------------------------------------------------------------------------
# Administration KEK / rotations (admin seulement)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/admin/generer-kek")
async def api_generer_kek(current_user=Depends(require_admin)):
_exiger_actives()
# La KEK générée n'est rendue qu'une fois, ici, et jamais stockée par
# ObsiGate : l'opérateur doit la sauvegarder hors dépôt immédiatement.
return {"kek": generate_kek_b64()}
@router.post("/api/coffres/{slug}/rotation-dek")
async def api_rotate_dek(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
coffre = rotate_dek(slug)
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(_username(current_user), "dek_rotation", coffre=coffre["slug"],
resultat="ok")
return _metadata_coffre(coffre)
@router.post("/api/coffres/admin/rotation-kek")
async def api_rotate_kek(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
count = rotate_kek(body.get("nouvelle_kek", ""))
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(_username(current_user), "kek_rotation", resultat="ok")
return {"status": "ok", "coffres_rechiffres": count}
+1 -1
View File
@@ -49,7 +49,7 @@ async def api_events(current_user=Depends(require_auth)):
try:
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
except asyncio.TimeoutError:
except TimeoutError:
# Keepalive comment
yield ": keepalive\n\n"
except asyncio.CancelledError:
+12 -12
View File
@@ -26,7 +26,7 @@ import logging
import os
import threading
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
@@ -118,7 +118,7 @@ def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
try:
parsed = datetime.fromisoformat(at)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
parsed = parsed.replace(tzinfo=UTC)
except ValueError:
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
return {"kind": kind, "at": parsed.isoformat()}
@@ -138,7 +138,7 @@ def create_task(
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
validated_action = _validate_action(action)
validated_schedule = _validate_schedule(schedule)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
with _lock:
tasks = _read_tasks()
task = {
@@ -200,9 +200,9 @@ def delete_task(task_id: str) -> bool:
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
"""Compute the next due datetime for *task*."""
now = now or datetime.now(timezone.utc)
now = now or datetime.now(UTC)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
now = now.replace(tzinfo=UTC)
schedule = task.get("schedule", {})
kind = schedule.get("kind")
last_run_at = task.get("last_run_at")
@@ -211,7 +211,7 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
try:
last = datetime.fromisoformat(str(last_run_at))
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
last = last.replace(tzinfo=UTC)
except ValueError:
last = None
if kind == "interval_hours":
@@ -233,11 +233,11 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
try:
at = datetime.fromisoformat(str(schedule.get("at")))
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
at = at.replace(tzinfo=UTC)
except ValueError:
return now
if task.get("last_run_at"):
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
return datetime.max.replace(tzinfo=UTC) # déjà exécutée
return at
return now + timedelta(hours=24)
@@ -278,7 +278,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
try:
result = _execute_action(task)
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_run_at"] = datetime.now(UTC).isoformat()
task["last_status"] = "ok"
task["last_error"] = None
task["run_count"] = int(task.get("run_count", 0)) + 1
@@ -292,7 +292,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
return {"task_id": task_id, "ok": True, "result": result}
except Exception as e:
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_run_at"] = datetime.now(UTC).isoformat()
task["last_status"] = "error"
task["last_error"] = str(e)
task["run_count"] = int(task.get("run_count", 0)) + 1
@@ -315,7 +315,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
"""Run every due task. Returns per-task outcomes (empty when idle)."""
now = now or datetime.now(timezone.utc)
now = now or datetime.now(UTC)
outcomes: list[dict[str, Any]] = []
for task in _read_tasks():
if not task.get("enabled", True):
@@ -323,7 +323,7 @@ def tick(now: datetime | None = None) -> list[dict[str, Any]]:
try:
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
next_run = next_run.replace(tzinfo=UTC)
except ValueError:
next_run = compute_next_run(task, now)
if next_run <= now:
+3 -3
View File
@@ -14,7 +14,7 @@ import logging
import os
import shutil
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -145,7 +145,7 @@ def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any
except (ValueError, TypeError):
continue
try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError) as ts_err:
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
continue
@@ -219,7 +219,7 @@ def diff_backup(
status=500,
details={"vault": vault_name, "path": path, "version": ts},
) from e
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
dt = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
return content, f"{path}@{dt}"
try:
+1 -1
View File
@@ -117,7 +117,7 @@ def list_recent(
for v_name, f in recent:
iso_modified = f.get("modified", "")
try:
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
mtime_dt = datetime.fromisoformat(iso_modified)
mtime_val = mtime_dt.timestamp()
except Exception:
mtime_val = time.time()
+5 -4
View File
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
from __future__ import annotations
from datetime import UTC
from pathlib import Path
from typing import Any
@@ -49,7 +50,7 @@ def get_vault_root(vault_name: str) -> Path:
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"""Return the direct children of a vault directory (directories first)."""
from datetime import datetime, timezone
from datetime import datetime
from backend.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting
@@ -98,7 +99,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"type": "file",
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"extension": entry.suffix.lower(),
})
except PermissionError:
@@ -156,7 +157,7 @@ def list_all_files(
ServiceError: ``not_found`` (404) if the directory does not exist,
``permission_denied`` (403) if it cannot be read.
"""
from datetime import datetime, timezone
from datetime import datetime
from backend.indexer import IGNORED_DIRS
@@ -215,7 +216,7 @@ def list_all_files(
"vault": vault_name,
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"extension": ext.lstrip(".") if ext else "",
"tags": _indexed_tags(vault_name, rel_path),
}
+5 -5
View File
@@ -11,7 +11,7 @@ import json
import logging
import secrets
import threading
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
logger = logging.getLogger("obsigate.share")
@@ -58,7 +58,7 @@ def create_share(
expires_at = None
if expires_in_hours:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
expires_at = (datetime.now(UTC) + timedelta(hours=expires_in_hours)).isoformat()
share = {
"id": token,
@@ -66,7 +66,7 @@ def create_share(
"vault": vault,
"path": path,
"created_by": created_by,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
"expires_at": expires_at,
"access_count": 0,
"last_accessed": None,
@@ -86,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
return None
if share.get("expires_at"):
expires = datetime.fromisoformat(share["expires_at"])
if datetime.now(timezone.utc) > expires:
if datetime.now(UTC) > expires:
return None
return share
@@ -98,7 +98,7 @@ def record_access(token: str):
share = data["shares"].get(token)
if share:
share["access_count"] = share.get("access_count", 0) + 1
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
share["last_accessed"] = datetime.now(UTC).isoformat()
_write(data)
+2 -2
View File
@@ -7,7 +7,7 @@ Reuses the application audit log (``data/audit.log``, JSON lines) and adds an
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from backend.audit import _write_entry
@@ -41,7 +41,7 @@ def log_tool_call(
) -> None:
"""Append an ``ai_tool_call`` entry to the audit log."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "ai_tool_call",
"username": username,
"ip": ip or "unknown",
+1 -1
View File
@@ -244,7 +244,7 @@ class VaultWatcher:
key = event.get('dest') or event['src']
pending[key] = event
except asyncio.TimeoutError:
except TimeoutError:
if pending:
events_to_process = list(pending.values())
pending.clear()
+4 -4
View File
@@ -31,7 +31,7 @@ import os
import socket
import threading
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlparse
@@ -275,7 +275,7 @@ def create_webhook(
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
"last_fired_at": None,
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
"vault": _normalize_vault_filter(vault),
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
payload = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"data": data,
}
body = json.dumps(payload, default=str)
@@ -369,7 +369,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
update_webhook(wh["id"], {"last_fired_at": datetime.now(UTC).isoformat()})
except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}")
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.77.0"
version = "2.80.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.77.0"
version = "2.80.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.77.0",
"version": "2.80.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+4
View File
@@ -225,6 +225,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
| *BUG-119* | Panneau Spark : le « mode console » (surimpression iframe de la console du service d'agents) est bloqué par le navigateur (« This content is blocked ») | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Panneau Spark → « Console ⤢ » : l'iframe reste sur la page d'erreur du navigateur | `frame-src 'self' blob: https://agent.dracodev.net` dans la CSP servie ; test `tests/test_security_headers.py::test_csp_frame_src_allows_agent_console` | Cause : `frame-src` n'autorisait pas l'origine du service d'agents, pourtant déjà présente dans `connect-src` (le panneau texte fonctionnait). Aucune autre directive modifiée. Redéploiement Docker requis |
| *BUG-120* | Menu inter-apps : sur l'environnement dev, les URL sœurs servies (`window.DRACO_URLS` : ObsiGate, Flowdeck, TikTak, Kanbante) restent en noms **prod** alors que l'URL de l'agent est bien en `-dev` | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/draco_config.py` (`app_url`, `_suffixe_environnement`) | Sur og-dev : `window.DRACO_URLS` donne `agent: https://agent-dev.dracodev.net` mais `tiktak: https://tiktak.dracodev.net` (prod) — le `.env` dev ne déclare pas les quatre `DRACO_<APP>_URL` | `backend/draco_config.py` : résolution sœur en 3 temps — explicite, puis **dérivation d'environnement** depuis l'hôte de `agent_url()` (`agent<SUFFIXE>.dracodev.net` → `https://<slug><SUFFIXE>.dracodev.net`, slugs `og`/`flowdeck`/`tiktak`/`kanbante` ; hors motif → rien), puis défaut officiel ; Gitea exclue. Tests `tests/test_draco_config.py` (+9 : dérivation `-dev` via `AGENT_EVENEMENTS_URL` et via `DRACO_AGENT_URL`, priorité de l'explicite, 5 hôtes hors motif, suffixe vide, injection) | Suite de #223 : seule l'URL de l'agent avait une dérivation de repli (`AGENT_EVENEMENTS_URL`) ; les sœurs retombaient sur les défauts officiels. Correctif de code demandé par Bruno : l'environnement se déduit de l'agent déjà configuré, sans variable supplémentaire. Redéploiement Docker requis |
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|---|---|---|---|---|---|---|---|---|---|
@@ -357,6 +359,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
| 2026-10-23 | BUG-119 | Correction | `backend/main.py`, `tests/test_security_headers.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | **BUG-119 — le « mode console » du panneau Spark V3 (#222) était bloqué par le navigateur** (« This content is blocked ») : la console du service d'agents s'affiche dans une iframe `https://agent.dracodev.net`, mais la CSP servie par `SecurityHeadersMiddleware` limitait `frame-src` à `'self' blob:` — seul le panneau texte passait (`connect-src` autorisait déjà l'origine). Correctif strict : `frame-src 'self' blob: https://agent.dracodev.net`, toutes les autres directives inchangées à l'identique. Test de non-régression `test_csp_frame_src_allows_agent_console` (la CSP servie est analysée et la liste exacte des sources `frame-src` verrouillée). Vérifié : 100 passed (security_headers + csp_nonce + security_hardening + draco_barre + version), ruff propre sur les fichiers touchés (51 erreurs préexistantes ailleurs, inchangées), mypy 0 (120 fichiers) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-25 | BUG-120 (suite #223) | Correction | `backend/draco_config.py`, `tests/test_draco_config.py`, `.env.example`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/devprod-urls-config-223.md` | **BUG-120 — le menu inter-apps de la dev pointait la prod** : `window.DRACO_URLS` servait l'agent en `-dev` mais les quatre sœurs en noms officiels, le `.env` dev ne déclarant pas les `DRACO_<APP>_URL`. Correctif dans `draco_config.app_url()` : après l'explicite, **dérivation d'environnement** — hôte de `agent_url()` de la forme `agent<SUFFIXE>.dracodev.net` sans port → sœur en `<slug><SUFFIXE>.dracodev.net` ; hors motif (autre domaine, LAN, IP, port) aucune dérivation ; Gitea jamais dérivée. Vérifié : `test_draco_config.py` 16 passed, suite complète **1668 passed / 9 skipped**, ruff 0 et mypy 0 sur les fichiers touchés (51 erreurs ruff préexistantes ailleurs, inchangées) | 🟢 corrigé (en attente vérif utilisateur) |
---
+61 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.77.0 | **Dernière mise à jour :** 2026-10-23
> **Version :** 2.80.2 | **Dernière mise à jour :** 2026-10-27
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -30,9 +30,64 @@
## 🔵 En cours
### 225. Fusion — panneau Spark : version du service d'agents visible en mode sidebar
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-26 | **Statut :** ✅ livré le 2026-10-26 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md)
- **Description :** demande de Bruno (2026-10-26, carte fusion #64) : la
version « Service d'agents vX », déjà visible dans la console pleine
page, doit se lire aussi dans le **panneau natif des apps** (mode
sidebar). Le pied du panneau Spark affiche désormais cette ligne,
discrète et permanente, lue **en direct** dans la santé du service
(`GET /healthz` de l'origine injectée par l'hôte) à l'ouverture du
panneau, avec cache de session — jamais une valeur codée en dur ;
service injoignable = rien d'affiché. Même livraison : TikTak
0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
- **Sous-tâches :**
- [x] Ligne de version dans le pied du panneau (canonique TikTak, propagée aux 4 apps)
- [x] Test `tests/test_draco_barre.py::test_panneau_version_service_affichee_225` + épreuve comportementale jsdom sur les 4 copies
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
### 224. Sécurité des dépendances — pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
- **Effort :** ~0,1 jour | **Impact :** 🔴 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/securite-dependances-pip-audit-224.md](./features/securite-dependances-pip-audit-224.md)
- **Description :** le job `security` du run CI 2303 (2.78.0) échoue
sur `pip-audit` : 17 vulnérabilités dans 4 paquets hérités de
l'environnement du runner (toolcache « already satisfied », même
piège que BUG-093). Planchers explicites dans
`backend/requirements.txt` : pypdf relevé à `>=6.21.2`, nouveaux
planchers `brotli>=1.2.1`, `filelock>=4.2.1`,
`opentelemetry-api>=1.45.1` ; garde-fou `TestDependencySecurityFloors`
étendu. Aucun code applicatif, aucun nouvel ignore pip-audit.
- **Sous-tâches :**
- [x] Planchers de sécurité dans `backend/requirements.txt` + commentaires
- [x] Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` étendu (pypdf 6.21.2, brotli, filelock, opentelemetry-api)
- [x] `pip-audit` local : 0 vulnérabilité non ignorée (mêmes ignores que la CI)
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
### 223. Dev/prod sans noms publics en dur — URLs de l'écosystème par configuration
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis, et en dev déclarer les noms en `-dev` dans le `.env` du poste)
- **Fiche :** [features/devprod-urls-config-223.md](./features/devprod-urls-config-223.md)
- **Description :** aucun nom public de l'écosystème dracodev ne doit être
figé dans le code vivant : les URL publiques (service d'agents, autres
apps) viennent de la configuration (défauts = noms **officiels** sans
suffixe ; l'environnement dev déclare ses noms en `-dev` dans son
`.env`). CSP dérivée de l'origine de l'agent configurée, panneau Spark
et liens inter-apps alimentés par l'hôte (injection `window.DRACO_URLS`
au service de `index.html`), références croisées reconstruites depuis
la config à l'affichage (aucune donnée migrée).
- **Sous-tâches :**
- [x] Module `backend/draco_config.py` + `.env.example`
- [x] CSP `connect-src`/`frame-src` dérivée de la config (tests via config)
- [x] `external_refs` : hôtes reconnus + URL d'affichage construites par config
- [x] Frontend : panneau Spark, liens TikTak, onglets des apps via `DRACO_URLS`
- [x] WebAuthn : confirmation rp_id/origines dérivés de la requête / `OBSIGATE_WEBAUTHN_*`
### 222. Fusion — panneau Spark V3 : la console en surimpression pleine page
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-23 | **Statut :** 🔵 en cours
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-23 | **Statut :** ✅ livré le 2026-10-23 (2.77.0, redéploiement Docker en attente)
- **Fiche :** [features/fusion-panneau-console-surimpression-222.md](./features/fusion-panneau-console-surimpression-222.md)
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
octet-identique dans les 4 apps) passe en V3 (demande de Bruno,
@@ -51,7 +106,7 @@
dans les 4 apps
- [x] Test `tests/test_draco_barre.py::test_panneau_console_surimpression_222`
(et révision du test #221 : le nouvel onglet disparaît)
- [ ] Release v2.77.0 publiée — CI à constater, redéploiement Docker (geste de Bruno)
- [x] Release v2.77.0 publiée — CI à constater, redéploiement Docker (geste de Bruno)
### 221. Fusion — panneau Spark : bouton « Console ↗ » vers la console du service
@@ -1111,6 +1166,9 @@
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| BUG-119 | Panneau Spark — « mode console » bloqué par la CSP (`frame-src` sans l'origine du service d'agents) | 2.77.1 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
| BUG-120 | Menu inter-apps — les URL sœurs (`window.DRACO_URLS`) dérivent l'environnement de l'hôte de l'agent (`-dev` en dev), explicite prioritaire, Gitea inchangée (suite de #223) | 2.78.2 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
| 225 | Panneau Spark — version du service d'agents visible en mode sidebar (pied du panneau, lue en direct via `GET /healthz`) | 2.79.0 | [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md) |
---
+97
View File
@@ -0,0 +1,97 @@
# #223 — Dev/prod sans noms publics en dur : URLs de l'écosystème par configuration
> Ouvert le 2026-10-25 (chantier dev/prod de Bruno). Contexte : l'installation
> actuelle (`deepin.lab.home`) devient l'environnement **dev** (noms en `-dev`),
> la future installation (`jump.point.mint.home`) sera la **prod** (noms
> officiels sans suffixe), identique sauf noms et clés. Règle : aucun nom
> public de l'écosystème codé en dur dans le code vivant ; défauts = noms
> officiels ; le dev déclare ses `-dev` dans son seul `.env`.
## Configuration (nouveau module `backend/draco_config.py`)
Lecture à l'appel, jamais à l'import. Défauts officiels :
| Variable | Défaut officiel | Rôle |
|---|---|---|
| `DRACO_AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents. À défaut : origine dérivée d'`AGENT_EVENEMENTS_URL`, puis défaut. |
| `DRACO_OBSIGATE_URL` | `https://og.dracodev.net` | Liens profonds ObsiGate |
| `DRACO_FLOWDECK_URL` | `https://flowdeck.dracodev.net` | Liens profonds Flowdeck |
| `DRACO_TIKTAK_URL` | `https://tiktak.dracodev.net` | Liens profonds TikTak |
| `DRACO_KANBANTE_URL` | `https://kanbante.dracodev.net` | Onglet Kanbante de la barre |
| `DRACO_GITEA_URL` | `https://git.dracodev.net` | Cartes Kanbante (= issues Gitea). Distincte d'`OBSIGATE_GITEA_URL` (source connectée de l'assistant). |
`AGENT_EVENEMENTS_URL` (existant, #220) reste la configuration première du
journal d'événements ; `agent_journal._url()` retombe sur
`DRACO_AGENT_URL + /api/v1/evenements` quand elle est absente.
## Backend
- **CSP** (`backend/main.py`, `SecurityHeadersMiddleware`) : `connect-src` et
`frame-src` utilisent `draco_config.agent_origine()` — dérivée de la config
à chaque réponse, plus aucun littéral. Tests dans
`tests/test_security_headers.py` **via la config** (défaut officiel neutralisé,
dérivation depuis `AGENT_EVENEMENTS_URL`, priorité `DRACO_AGENT_URL`).
- **Références croisées** (`backend/external_refs.py`) :
- `parse_deep_link` reconnaît les hôtes officiels **et** configurés
(`draco_config.hotes_reconnus()`) — un lien collé depuis le dev comme
depuis la prod est reconnu.
- `build_deep_link(app, type, id)` construit l'URL d'affichage depuis la
config ; `list_refs` / `get_ref` / `delete_ref` servent des copies dont
`target_url` est **reconstruite à la lecture**. ⚠️ Les références
**stockées** (`data/external_refs/*.json`) gardent l'URL absolue collée
par l'utilisateur : aucune migration, la valeur stockée n'est simplement
plus jamais servie telle quelle.
- **WebAuthn — confirmé, aucun changement** : `resolve_relying_party()`
(`backend/auth/webauthn_mfa.py`, BUG-070) dérive `rp_id`/origines de la
requête (hôte exact, `X-Forwarded-Host`/`X-Forwarded-Proto` honorés si
`OBSIGATE_TRUST_PROXY=true`), et `OBSIGATE_WEBAUTHN_RP_ID` /
`OBSIGATE_WEBAUTHN_ORIGINS` priment quand ils sont posés. Aucun nom figé.
## Frontend (contrat d'injection de dracodev-commons)
L'hôte fournit les URL : `backend/main.py::_index_html()` injecte
`window.DRACO_URLS = {…}` (JSON issu de `draco_config.urls_publiques()`,
nonce CSP par le mécanisme existant) dans `index.html` au service.
- `frontend/draco-panneau.js` : `API = window.DRACO_URLS.agent || défaut officiel` ;
`appHote()` retire un suffixe `-dev` éventuel du premier label d'hôte
(`og-dev` → `obsigate` pour le lien profond `?app=`).
- `frontend/draco-liens.js` : « Ouvrir dans TikTak » =
`window.DRACO_URLS.tiktak || défaut officiel`.
- `frontend/index.html` : les `href` des onglets portent les défauts officiels ;
le script de configuration de la barre les réécrit depuis `DRACO_URLS`.
Le menu des apps (`draco-barre.js`) est construit depuis ces onglets : il
suit donc sans changement.
- Chemin réellement servi : `/static/draco-*.js` via le montage `StaticFiles`,
`Cache-Control: no-cache` posé par `SecurityHeadersMiddleware` sur
`/static/*`, et service worker **network-first** pour le code — aucun
cache-busting par empreinte n'existe (revalidation systématique).
## Correctif BUG-120 (2026-10-25) — dérivation d'environnement des sœurs
Constat sur la dev : le `.env` du poste ne déclare que l'agent (via
`AGENT_EVENEMENTS_URL`) ; les quatre `DRACO_<APP>_URL` absentes
retombaient sur les défauts prod dans `window.DRACO_URLS` — le menu
inter-apps pointait la prod depuis la dev. La résolution d'une URL
sœur (`backend/draco_config.py::app_url`) gagne une étape
intermédiaire, entre l'explicite et le défaut officiel :
**dérivation depuis l'hôte de l'agent** (`agent_url()`) — hôte de la
forme `agent<SUFFIXE>.dracodev.net` sans port → sœur =
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`, `flowdeck`,
`tiktak`, `kanbante`). Hôte hors motif (autre domaine, LAN, IP,
port) → aucune dérivation. Gitea exclue. Le dev n'a plus quatre
variables à déclarer ; un `.env` qui les déclare déjà est inchangé
(l'explicite prime).
## Restes assumés
- Suffixe SSO `dracodev.net` (`draco_session.validate_return_to`,
`frontend/js/auth.js`) : ce n'est pas un nom d'hôte mais le domaine parent
commun aux deux environnements (les noms `-dev` restent des sous-domaines) ;
le domaine du cookie est déjà configurable (`DRACO_SESSION_COOKIE_DOMAIN`).
- Mentions textuelles (titres/messages du panneau, contact OpenAPI et lien
« À propos » vers le dépôt Git) : infra Git hors périmètre, ou libellés.
- `OBSIGATE_SEARXNG_URL` : déjà configurable, défaut officiel conservé.
- Le panneau reste un asset partagé : la même ligne d'injection doit être
recopiée dans les 3 autres apps par leurs chantiers respectifs pour
retrouver l'octet-identique.
@@ -0,0 +1,40 @@
# #225 — Fusion : panneau Spark, version du service d'agents visible en mode sidebar
**Contexte.** La version « Service d'agents vX » du service d'agents
est visible dans la console pleine page (pilule d'en-tête, rendue par
le service lui-même), mais pas dans le **panneau natif des
applications** (mode sidebar) — or c'est là que Bruno vit au
quotidien. Demande de Bruno (2026-10-26), carte fusion #64 : la
version doit se lire dans le panneau ouvert, sans ouvrir la console.
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
maintenu en canonique dans TikTak et recopié dans les 4 applications
(TikTak, Flowdeck, Kanbante, ObsiGate ; chaque copie garde son bloc
d'injection d'origine propre à l'hôte, le corps est commun) :
- le pied du panneau gagne une ligne dédiée
`.draco-panneau-version`, discrète (11,5 px, opacité 0,6,
monochrome, aucun emoji), **masquée tant qu'aucune version n'est
connue** ;
- à l'ouverture du panneau, `chargerVersion()` interroge la santé
du service — `GET /healthz` de l'origine que le panneau connaît
déjà (celle injectée par l'hôte, ici `window.DRACO_URLS.agent`) —
et affiche « Service d'agents vX » depuis le champ `version` de
la réponse (la même source que la pilule de la console) ;
- cache de session de 5 minutes, rafraîchissement au rythme des
ouvertures du panneau (comme la vérification d'identité) ;
- **jamais de valeur codée en dur** : réponse illisible ou service
injoignable = la ligne reste masquée — pas de fausse version, pas
d'erreur visible.
**Preuve.** Épreuve comportementale (jsdom) exécutée sur les 4
copies avec le contrat d'injection de chaque hôte : santé joignable
→ la ligne affiche « Service d'agents v0.38.0 » (version lue sur
l'origine de l'agent) ; santé injoignable → rien d'affiché ; hôte
sans injection → aucun appel. Test de garde :
`tests/test_draco_barre.py::test_panneau_version_service_affichee_225`.
**Même livraison** (carte fusion #64) : TikTak 0.61.3, Flowdeck
7.83.1, Kanbante 0.37.1. Côté ObsiGate, la prise d'effet attend le
**redéploiement Docker** (geste de Bruno), comme les items panneau
précédents.
@@ -0,0 +1,94 @@
# #224 — Sécurité des dépendances : pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
> Ouvert le 2026-10-25. Déclencheur : le job `security` du run CI 2303
> (commit `f29a228`, version 2.78.0) échoue sur `pip-audit` (bloquant,
> #87) — **17 vulnérabilités connues dans 4 paquets**. Le job `lint`
> du même run est vert ; `security` est le seul job terminé en échec
> au moment du constat (`test` en file, `build`/`e2e` en attente).
## Constat (journal du job `security`, run 2303)
| Paquet | Version auditée | Vulnérabilités | Corrigée en |
|---|---|---|---|
| brotli | 1.2.0 | PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 | 1.2.1 |
| filelock | 3.32.3 | CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 | 4.2.1 |
| opentelemetry-api | 1.37.0 | CVE-2026-114603 | 1.45.1 |
| pypdf | 6.19.0 | CVE-2026-113792, CVE-2026-113795, CVE-2026-113793, CVE-2026-113794, CVE-2026-113786, CVE-2026-113791, CVE-2026-113790, CVE-2026-113789, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580 | 6.20.0 → 6.21.2 selon l'advisory (max 6.21.2) |
Total : 2 + 3 + 1 + 11 = **17 vulnérabilités**.
Audit local de confirmation, en deux temps : (1) un venv épinglé sur
les versions du runner (brotli 1.2.0, filelock 3.32.3,
opentelemetry-api 1.37.0, pypdf 6.19.0) reproduit **exactement** les
17 vulnérabilités et les versions corrigées du tableau ci-dessus ;
(2) dans le venv du dépôt, `pip-audit` (avec les deux ignores déjà en
place dans la CI) ne ressortait avant correctif que brotli 1.2.0
(2 advisories, fix 1.2.1) et pypdf 6.21.1 (3 advisories restants,
fix 6.21.2) — ce venv a déjà filelock 4.2.4 (corrigée) et ne contient
pas opentelemetry-api. L'écart avec la CI vient de l'environnement
audité, pas des dépendances déclarées (voir cause).
## Cause racine
Même piège que BUG-093 / BUG-095, déjà documenté dans
`backend/requirements.txt` et `.gitea/workflows/ci.yml` : `pip-audit`
audite **tout l'environnement Python du runner**, dont les paquets
préinstallés dans la toolcache de l'image `catthehacker/ubuntu:act-latest`.
`pip install -r backend/requirements.txt` répond « already satisfied »
pour toute exigence transitive déjà satisfaite par la toolcache et
n'aligne jamais la version vulnérable :
- **pypdf 6.19.0** : préinstallée ; le plancher `>=6.16.1` (BUG-093)
est satisfait, pip ne monte pas vers 6.21.2.
- **brotli 1.2.0** : transitive de `fonttools[woff]` (via weasyprint),
exigée en `>=1.0.1` seulement.
- **filelock 3.32.3** : transitive de CacheControl (outil `pip-audit`
lui-même) et préinstallée ; exigée en `>=3.8.0` seulement.
- **opentelemetry-api 1.37.0** : préinstallée dans la toolcache,
aucune dépendance d'ObsiGate ne la demande.
## Correctif
Planchers de sécurité explicites dans `backend/requirements.txt`,
selon le patron déjà en place (pyjwt, urllib3, multidict, certifi) —
aucune autre dépendance touchée, aucun code applicatif modifié :
- `pypdf>=6.16.1` → **`pypdf>=6.21.2`** (plancher existant relevé) ;
- **`brotli>=1.2.1`** (nouveau plancher) ;
- **`filelock>=4.2.1`** (nouveau plancher) ;
- **`opentelemetry-api>=1.45.1`** (nouveau plancher).
Garde-fou : `tests/test_ci_workflow.py::TestDependencySecurityFloors`
couvre désormais aussi brotli, filelock et opentelemetry-api, et le
plancher verrouillé de pypdf passe à (6, 21, 2). Le commentaire de
l'étape Pip-audit de `.gitea/workflows/ci.yml` est mis à jour en
conséquence (aucune commande CI modifiée).
## Exceptions (ignores)
**Aucun nouvel ignore.** Les deux exceptions déjà en place dans la
CI restent inchangées et ne concernent pas ces paquets :
`PYSEC-2026-1325` (ecdsa, aucun correctif upstream, chemins ECDSA
jamais exécutés — HS256 uniquement) et `CVE-2026-85394`
(python-jose, aucun correctif upstream, non atteignable — voir le
commentaire de l'étape dans `ci.yml`).
## Vérification
- `pip-audit` local (mêmes ignores que la CI) après alignement de
l'environnement sur les nouveaux planchers : **0 vulnérabilité
non ignorée**.
- Portes locales du workflow : `pytest tests/`, `ruff check backend/`,
`mypy backend/ --ignore-missing-imports`, tests frontend
(`validate-imports` + `unit`) — voir le commit #224.
- CI Gitea complète verte exigée (lint, test, security, build, e2e)
avant de considérer #224 livré.
## Livraison
- Commit conventionnel `fix: … #224` ; le hook de version porte
2.78.0 → **2.78.1** (correctif) et crée le tag `v2.78.1`, publié
au push ; release Gitea créée sur ce tag après vérification de sa
cible.
- **Redéploiement Docker requis** pour que l'image embarque les
dépendances alignées (l'hôte actuel est l'environnement dev).
+7 -1
View File
@@ -23,6 +23,12 @@
kanbante: "#0d9488",
};
/* Base TikTak fournie par l'hôte via window.DRACO_URLS (contrat
* d'injection dracodev-commons, #223) ; repli = défaut officiel. */
function baseTikTak() {
return (window.DRACO_URLS && window.DRACO_URLS.tiktak) || "https://tiktak.dracodev.net";
}
function t(key, fallback) {
try {
var v = window.t && window.t(key);
@@ -92,7 +98,7 @@
t("draco.links.projected", "Tâche TikTak projetée")));
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
ouvrir.href = baseTikTak() + "/?task=" + tid[1];
ouvrir.target = "_blank";
ouvrir.rel = "noopener";
bandeau.appendChild(ouvrir);
+40 -2
View File
@@ -32,13 +32,17 @@
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
/* URL du service : fournie par l'hôte via window.DRACO_URLS
* (contrat d'injection dracodev-commons, #223) — le repli est le
* défaut officiel ; aucun nom d'environnement n'est figé ici. */
var API = (window.DRACO_URLS && window.DRACO_URLS.agent) || "https://agent.dracodev.net";
var convId = null;
var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -56,7 +60,9 @@
/* ── Console web (même service, surimpression pleine page) ─── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
/* Premier label de l'hôte, suffixe d'environnement éventuel
* retiré (ex. « og-dev » → « og » en dev, #223). */
var h = (window.location.hostname || "").split(".")[0].replace(/-dev$/, "");
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
@@ -264,6 +270,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -272,6 +279,7 @@
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -326,6 +334,35 @@
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
@@ -802,6 +839,7 @@
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
+10
View File
@@ -397,6 +397,16 @@
localement ; la recherche focus la recherche native
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
fédération / l'agent commun (phases suivantes). */
/* #223 : les href des onglets portent les défauts officiels ;
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
backend depuis sa configuration) quand il est présent. */
(function () {
if (!window.DRACO_URLS) return;
document.querySelectorAll(".draco-apptab").forEach(function (a) {
var u = window.DRACO_URLS[a.dataset.app];
if (u) a.href = u.replace(/\/$/, "") + "/";
});
})();
window.DRACO_BAR_CONFIG = {
current: "obsigate",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
File diff suppressed because it is too large Load Diff
+177
View File
@@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js';
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js';
import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
import { filterChatMessages as _filterChat } from './filechat.js';
@@ -46,6 +47,168 @@ function _sharedIcon(size = 16) {
return i;
}
// ---------------------------------------------------------------------------
// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19).
// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine
// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans
// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent
// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le
// coffre personnel à la première ouverture si besoin.
// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants :
// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
// ---------------------------------------------------------------------------
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
function _secretsIcon() {
const s = document.createElement("span");
s.className = "coffres-icon-lock";
s.textContent = "\u{1F510}";
return s;
}
function _isOwnHome(vaultName) {
const me = (AuthManager.getUser() || {}).username;
return !!me && vaultName === `home-${me}`;
}
/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */
async function _buildSecretsNode(vaultName) {
const pair = document.createDocumentFragment();
const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]);
const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` });
item.addEventListener("click", async () => {
scrollTreeItemIntoView(item, false);
if (sub.classList.contains("collapsed")) {
if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName);
sub.classList.remove("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
sub.classList.add("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
await openCoffresView();
closeMobileSidebar();
});
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]);
actionBtn.addEventListener("click", (e) => {
e.stopPropagation();
const r = actionBtn.getBoundingClientRect();
_openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom);
});
item.appendChild(actionBtn);
pair.appendChild(item);
pair.appendChild(sub);
// Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue.
_fillSecretsNode(item, sub, vaultName).catch(() => {});
return pair;
}
/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */
async function _fillSecretsNode(item, sub, vaultName) {
const data = await getCoffresForSidebar();
item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove());
if (!data.disabled && data.personnel) {
if (!data.personnel.kek_configuree) {
const dot = document.createElement("span");
dot.className = "coffres-alert-dot";
dot.textContent = "\u26A0";
dot.title = t("coffres.kek_absente");
item.appendChild(dot);
} else {
item.appendChild(smallBadge(data.personnel.nb_secrets));
}
}
sub.innerHTML = "";
const coffres = (data.coffres || []).slice().sort((a, b) => {
const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1;
const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1;
return aPers - bPers || String(a.nom).localeCompare(String(b.nom));
});
coffres.forEach((c) => {
const me = (AuthManager.getUser() || {}).username;
const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`;
const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]);
child.addEventListener("click", async () => {
scrollTreeItemIntoView(child, false);
await openCoffresView(c.slug);
closeMobileSidebar();
});
child.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
sub.appendChild(child);
});
safeCreateIcons();
}
/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */
async function _refreshSecretsNodes() {
const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]'));
for (const item of items) {
const vaultName = item.dataset.vault;
const sub = item.nextElementSibling;
if (sub && sub.classList.contains("tree-children")) {
await _fillSecretsNode(item, sub, vaultName);
}
}
}
if (!window.__obsigateSecretsListener) {
window.__obsigateSecretsListener = true;
document.addEventListener("obsigate:coffres-changed", () => {
invalidateCoffresCache();
_refreshSecretsNodes().catch(() => {});
});
}
/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal.
* Menu propre, jamais ContextMenuManager (pas d'action de fichier). */
function _openSecretsMenu(x, y) {
document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove());
const menu = el("div", { class: "coffres-context-menu" }, []);
const entries = [
[t("coffres.detail"), () => openCoffresView()],
[t("coffres.new_secret"), () => openCoffresNewSecret()],
[t("coffres.acces"), () => openCoffresAcces()],
[t("coffres.journal"), () => openCoffresJournal()],
];
entries.forEach(([label, run]) => {
const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]);
b.addEventListener("click", async () => {
menu.remove();
await run();
closeMobileSidebar();
});
menu.appendChild(b);
});
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px";
menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px";
const closer = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener("click", closer, true);
document.removeEventListener("keydown", onKey);
}
};
const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } };
setTimeout(() => {
document.addEventListener("click", closer, true);
document.addEventListener("keydown", onKey);
}, 0);
}
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
@@ -494,6 +657,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
// Replace container content in a single batch operation to avoid flash
container.textContent = "";
container.appendChild(fragment);
@@ -838,6 +1007,14 @@ async function loadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine
// du Home de l'utilisateur courant seulement, jamais sous un autre Home
// ni sous un vault de notes, et jamais comme un dossier physique.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
container.appendChild(fragment);
safeCreateIcons();
}
+92 -1
View File
@@ -2666,5 +2666,96 @@
"guide105.dl_md_title": "Download this guide as Markdown",
"guide105.dl_pdf_title": "Download this guide as PDF",
"guide105.export_title": "ObsiGate User Guide",
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application."
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application.",
"coffres.acces": "Access and consumers",
"coffres.actions": "Actions",
"coffres.active": "Active",
"coffres.confirm_dek_rotate": "Rotate this vault's DEK? Secrets will be re-encrypted, with no change for consumers.",
"coffres.confirm_delete": "Delete this secret? The following grants will break:",
"coffres.confirm_revoke_consumer": "Revoke this consumer? All of its distributions will be denied.",
"coffres.confirm_revoke_octroi": "Revoke this grant? Access will be denied on the next call.",
"coffres.consumer_no_distribution": "No distribution in the recent log.",
"coffres.consumers": "Consumers",
"coffres.copied": "Copied.",
"coffres.copy_failed": "Copy failed.",
"coffres.copy_generated": "Copy the generated value",
"coffres.copy_reference": "Copy",
"coffres.create_coffre_title": "New vault",
"coffres.create_octroi": "Create a grant",
"coffres.create_secret_title": "New secret",
"coffres.created": "Created",
"coffres.dek_rotate": "Rotate DEK",
"coffres.delete": "Delete",
"coffres.deleted": "Secret deleted.",
"coffres.description": "Description",
"coffres.detail": "Details",
"coffres.disabled": "Secret vaults are disabled on this instance (OBSIGATE_SECRETS_ENABLED).",
"coffres.expiration": "Expiration",
"coffres.expired": "Expired",
"coffres.expires": "Expires",
"coffres.expiring_soon": "Expiring soon",
"coffres.generate": "Generate",
"coffres.generate_unavailable": "Generation is unavailable in this browser.",
"coffres.grant_all_coffre": "Whole vault",
"coffres.journal": "Access log",
"coffres.kek_absente": "Master key missing: metadata is visible, but no secret can be created or distributed.",
"coffres.kek_generate": "Generate a KEK",
"coffres.kek_generate_hint": "The generated KEK is returned only once and is never stored by ObsiGate: back it up outside the repository immediately.",
"coffres.kek_new": "New KEK",
"coffres.kek_ok": "Master key configured.",
"coffres.kek_once_hint": "Copy it now and back it up outside the repository: it will never be shown again.",
"coffres.kek_rotate": "Rotate KEK",
"coffres.kek_rotated": "KEK rotated. Remember to replace the KEK in the environment or KEK file.",
"coffres.kek_rotation_hint": "After rotation, replace the KEK in the environment or KEK file, otherwise distributions will fail.",
"coffres.last_use": "Last use",
"coffres.loading": "Loading vaults…",
"coffres.manual_copy": "One-time manual copy",
"coffres.manual_copy_admin_only": "Admin only (no session-based distribution endpoint exists yet on the backend).",
"coffres.manual_copy_confirm": "A temporary consumer will be created, the value will be copied to the clipboard without being shown, then the consumer will be revoked immediately. The distribution will be audited.",
"coffres.modified": "Modified",
"coffres.name": "Name",
"coffres.name_hint": "Lowercase, digits, - or _ ; 3 to 128 characters (e.g. gitea-token).",
"coffres.name_required": "A name is required.",
"coffres.never_in_note": "Never paste the value into the note.",
"coffres.new_coffre": "New vault",
"coffres.new_consumer": "New consumer",
"coffres.new_secret": "New secret",
"coffres.no_coffre": "No visible vault.",
"coffres.no_secrets": "No secret in this vault.",
"coffres.note": "Note",
"coffres.octroi_created": "Grant created.",
"coffres.octroi_default_90": "Empty = default expiration (90 days).",
"coffres.octroi_no_expiration": "No expiration",
"coffres.octroi_revoked": "Grant revoked.",
"coffres.octrois": "Grants",
"coffres.perimetre": "Scope",
"coffres.propose_octroi": "Propose a grant (optional)",
"coffres.reference": "Reference",
"coffres.revoke": "Revoke",
"coffres.rotated": "Secret rotated: the new version is active.",
"coffres.rotation": "Rotate",
"coffres.rotation_title": "Rotate secret",
"coffres.save": "Save",
"coffres.saved": "Secret saved. The value will no longer be shown.",
"coffres.search_placeholder": "Filter secrets…",
"coffres.secret": "Secret",
"coffres.secret_saved_octroi_failed": "Secret saved, but the grant failed",
"coffres.secrets": "Secrets",
"coffres.select_secret": "Select a secret to see its metadata.",
"coffres.service": "Service",
"coffres.show_before_save": "Show before saving",
"coffres.slug": "Slug",
"coffres.slug_hint": "Lowercase, digits, - or _ ; 3 to 64 characters.",
"coffres.status": "Status",
"coffres.subtitle": "Secret vaults — metadata only, a value is never shown again after it is saved.",
"coffres.target_id": "Target id",
"coffres.target_id_required": "A target id is required.",
"coffres.target_type": "Target type",
"coffres.title": "Secrets",
"coffres.token_once": "Token (shown only once)",
"coffres.token_once_hint": "Copy it now: it will never be shown again.",
"coffres.type": "Type",
"coffres.value": "Value",
"coffres.value_required": "A value is required.",
"coffres.version": "Version"
}
+92 -1
View File
@@ -2666,5 +2666,96 @@
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
"guide105.export_title": "Guide d'utilisation ObsiGate",
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application."
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application.",
"coffres.acces": "Accès et consommateurs",
"coffres.actions": "Actions",
"coffres.active": "Actif",
"coffres.confirm_dek_rotate": "Pivoter la DEK de ce coffre ? Les secrets seront re-chiffrés, sans changement pour les consommateurs.",
"coffres.confirm_delete": "Supprimer ce secret ? Les octrois suivants seront cassés :",
"coffres.confirm_revoke_consumer": "Révoquer ce consommateur ? Toutes ses distributions seront refusées.",
"coffres.confirm_revoke_octroi": "Révoquer cet octroi ? L'accès sera refusé dès le prochain appel.",
"coffres.consumer_no_distribution": "Aucune distribution dans le journal récent.",
"coffres.consumers": "Consommateurs",
"coffres.copied": "Copié.",
"coffres.copy_failed": "Copie impossible.",
"coffres.copy_generated": "Copier la valeur générée",
"coffres.copy_reference": "Copier",
"coffres.create_coffre_title": "Nouveau coffre",
"coffres.create_octroi": "Créer un octroi",
"coffres.create_secret_title": "Nouveau secret",
"coffres.created": "Créé",
"coffres.dek_rotate": "Rotation DEK",
"coffres.delete": "Supprimer",
"coffres.deleted": "Secret supprimé.",
"coffres.description": "Description",
"coffres.detail": "Détail",
"coffres.disabled": "Les coffres de secrets sont désactivés sur cette instance (OBSIGATE_SECRETS_ENABLED).",
"coffres.expiration": "Expiration",
"coffres.expired": "Expiré",
"coffres.expires": "Expire",
"coffres.expiring_soon": "Expire bientôt",
"coffres.generate": "Générer",
"coffres.generate_unavailable": "Génération indisponible dans ce navigateur.",
"coffres.grant_all_coffre": "Tout le coffre",
"coffres.journal": "Journal d'accès",
"coffres.kek_absente": "Clé maître absente : les métadonnées sont visibles, mais aucun secret ne peut être créé ou distribué.",
"coffres.kek_generate": "Générer une KEK",
"coffres.kek_generate_hint": "La KEK générée n'est rendue qu'une fois et n'est jamais stockée par ObsiGate : sauvegardez-la hors dépôt immédiatement.",
"coffres.kek_new": "Nouvelle KEK",
"coffres.kek_ok": "Clé maître configurée.",
"coffres.kek_once_hint": "Copiez-la maintenant et sauvegardez-la hors dépôt : elle ne sera plus jamais affichée.",
"coffres.kek_rotate": "Rotation KEK",
"coffres.kek_rotated": "KEK pivotée. Pensez à remplacer la KEK dans l'environnement ou le fichier KEK.",
"coffres.kek_rotation_hint": "Après la rotation, remplacez la KEK dans l'environnement ou le fichier KEK, sinon les distributions échoueront.",
"coffres.last_use": "Dernière utilisation",
"coffres.loading": "Chargement des coffres…",
"coffres.manual_copy": "Copie manuelle à usage unique",
"coffres.manual_copy_admin_only": "Réservée aux administrateurs (aucun endpoint de distribution par session n'existe encore côté backend).",
"coffres.manual_copy_confirm": "Un consommateur temporaire sera créé, la valeur sera copiée au presse-papiers sans être affichée, puis le consommateur sera révoqué immédiatement. La distribution sera auditée.",
"coffres.modified": "Modifié",
"coffres.name": "Nom",
"coffres.name_hint": "Minuscules, chiffres, - ou _ ; 3 à 128 caractères (ex. gitea-token).",
"coffres.name_required": "Un nom est requis.",
"coffres.never_in_note": "Ne collez jamais la valeur dans la note.",
"coffres.new_coffre": "Nouveau coffre",
"coffres.new_consumer": "Nouveau consommateur",
"coffres.new_secret": "Nouveau secret",
"coffres.no_coffre": "Aucun coffre visible.",
"coffres.no_secrets": "Aucun secret dans ce coffre.",
"coffres.note": "Note",
"coffres.octroi_created": "Octroi créé.",
"coffres.octroi_default_90": "Vide = expiration par défaut (90 jours).",
"coffres.octroi_no_expiration": "Sans expiration",
"coffres.octroi_revoked": "Octroi révoqué.",
"coffres.octrois": "Octrois",
"coffres.perimetre": "Périmètre",
"coffres.propose_octroi": "Proposer un octroi (optionnel)",
"coffres.reference": "Référence",
"coffres.revoke": "Révoquer",
"coffres.rotated": "Secret pivoté : la nouvelle version est active.",
"coffres.rotation": "Rotation",
"coffres.rotation_title": "Rotation du secret",
"coffres.save": "Enregistrer",
"coffres.saved": "Secret enregistré. La valeur ne sera plus affichée.",
"coffres.search_placeholder": "Filtrer les secrets…",
"coffres.secret": "Secret",
"coffres.secret_saved_octroi_failed": "Secret enregistré, mais l'octroi a échoué",
"coffres.secrets": "Secrets",
"coffres.select_secret": "Sélectionnez un secret pour voir ses métadonnées.",
"coffres.service": "Service",
"coffres.show_before_save": "Afficher avant l'enregistrement",
"coffres.slug": "Identifiant (slug)",
"coffres.slug_hint": "Minuscules, chiffres, - ou _ ; 3 à 64 caractères.",
"coffres.status": "Statut",
"coffres.subtitle": "Coffres de secrets — métadonnées seules, aucune valeur n'est jamais affichée après l'enregistrement.",
"coffres.target_id": "Identifiant de la cible",
"coffres.target_id_required": "L'identifiant de la cible est requis.",
"coffres.target_type": "Type de cible",
"coffres.title": "Secrets",
"coffres.token_once": "Jeton (affiché une seule fois)",
"coffres.token_once_hint": "Copiez-le maintenant : il ne sera plus jamais affiché.",
"coffres.type": "Type",
"coffres.value": "Valeur",
"coffres.value_required": "Une valeur est requise.",
"coffres.version": "Version"
}
+32
View File
@@ -14331,3 +14331,35 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
color: var(--text-primary); font: inherit; font-size: 13.5px;
}
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
/* ---------------------------------------------------------------------------
* Coffre Secrets (Phase 1c, issue #19) — vue Secrets + nœud sidebar.
* Réutilise les variables de thème existantes ; aucune valeur de secret
* n'est jamais affichée par ces styles (colonne valeur = •••••• en texte).
* --------------------------------------------------------------------------- */
.coffres-view { padding: 16px 20px; max-width: 1100px; }
.coffres-subtitle { color: var(--text-muted); margin: 0 0 12px; }
.coffres-banner { padding: 10px 12px; margin: 12px 0; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.coffres-banner.warn { border-color: var(--warning, #f59e0b); }
.coffres-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.coffres-chip { white-space: nowrap; }
.coffres-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
.coffres-actions .modal-input { flex: 1 1 220px; }
.coffres-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.coffres-table th, .coffres-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #21262d); vertical-align: top; }
.coffres-table tbody tr { cursor: pointer; }
.coffres-table tbody tr:hover { background: var(--bg-hover, #1f2430); }
.coffres-detail { margin-top: 16px; padding: 14px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; }
.coffres-dl { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; margin: 12px 0; }
.coffres-dl dt { color: var(--text-muted); }
.coffres-dl dd { margin: 0; overflow-wrap: anywhere; }
.coffres-line { padding: 6px 0; border-bottom: 1px solid var(--border, #21262d); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: space-between; }
.coffres-empty { color: var(--text-muted); }
.coffres-alerts { margin: 12px 0; }
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
.coffres-icon-lock { font-size: 16px; line-height: 1; }
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.77.0",
"version": "2.80.2",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+7 -1
View File
@@ -251,11 +251,17 @@ class TestDependencySecurityFloors:
#: (paquet, plancher minimal, advisories corrigées au-dessus)
FLOORS = {
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
# #224 : 6.19.0 (toolcache) frappée par 11 CVE, corrigées au plus
# tard en 6.21.2 ; avant : PYSEC-2026-3910/3911 (fix 6.16.1).
"pypdf": (6, 21, 2),
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
"multidict": (6, 9, 1), # CVE-2026-104874 (fix 6.9.1 ; 6.7.x toolcache runner)
# #224 : transitives / préinstallées toolcache, auditées par pip-audit.
"brotli": (1, 2, 1), # PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 (fix 1.2.1)
"filelock": (4, 2, 1), # CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 (fix 4.2.1)
"opentelemetry-api": (1, 45, 1), # CVE-2026-114603 (fix 1.45.1)
}
def test_security_floors_are_declared(self):
+457
View File
@@ -0,0 +1,457 @@
"""Tests du coffre Secrets ObsiGate (Phase 1a / 1b, issues #17 et #18).
Couverture exigée par les issues :
- chiffrement/déchiffrement, tag invalide refusé, rotations (secret, DEK,
KEK), mode dégradé sans KEK ;
- matrice d'autorisation, y compris admin contre coffre personnel d'autrui
et ACL « * » qui ne donne aucun droit sur un coffre ;
- contexte manquant refusé, octroi révoqué refusé au prochain appel,
expiration respectée, audit complet sans valeur ;
- lectures = métadonnées seules, jamais de valeur ni de texte chiffré.
Toutes les valeurs de secret utilisées ici sont fictives et ne servent
qu'aux tests. Le store est isolé par test via OBSIGATE_DATA_DIR et le
journal d'audit via backend.audit.AUDIT_LOG_FILE.
"""
from __future__ import annotations
import base64
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend import audit as audit_module
from backend.auth.middleware import require_admin, require_auth
from backend.coffre_crypto import (
KEKAbsenteError,
KEKInvalideError,
TagInvalideError,
decrypt_secret_value,
encrypt_secret_value,
generate_dek,
generate_kek_b64,
load_kek,
parse_kek,
unwrap_dek,
wrap_dek,
)
from backend.coffre_store import (
CoffreExisteError,
SecretExpireError,
ValidationError,
create_coffre,
create_secret,
distribuer_valeur,
get_coffre_or_404,
rotate_dek,
rotate_kek,
rotate_secret,
)
from backend.routers.coffres import router as coffres_router
VALEUR_TEST = "VALEUR-TEST-FICTIVE-7f3a9c-ne-pas-utiliser"
VALEUR_TEST_2 = "VALEUR-TEST-FICTIVE-2-b4d8e1-ne-pas-utiliser"
# Utilisateur courant injecté dans les routes via dependency_overrides.
_COURANT = {"user": None}
def _fake_auth():
if _COURANT["user"] is None:
raise HTTPException(status_code=401, detail="Authentification requise")
return _COURANT["user"]
def _fake_admin():
user = _fake_auth()
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Accès admin requis")
return user
def _user(username: str, role: str = "user", vaults: list | None = None) -> dict:
return {
"username": username,
"role": role,
"vaults": vaults if vaults is not None else [f"home-{username}"],
"active": True,
}
@pytest.fixture
def env_coffre(tmp_path, monkeypatch):
"""Environnement isolé : store, audit, KEK et feature flag."""
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "1")
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path))
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
monkeypatch.setattr(audit_module, "AUDIT_LOG_FILE", tmp_path / "audit.log")
_COURANT["user"] = _user("bruno", role="admin")
yield tmp_path
_COURANT["user"] = None
@pytest.fixture
def client(env_coffre):
app = FastAPI()
app.include_router(coffres_router)
app.dependency_overrides[require_auth] = _fake_auth
app.dependency_overrides[require_admin] = _fake_admin
with TestClient(app, raise_server_exceptions=False) as test_client:
yield test_client
def _connecter(client, username: str, role: str = "user", vaults=None):
_COURANT["user"] = _user(username, role=role, vaults=vaults)
def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VALEUR_TEST):
resp = client.post(
"/api/coffres",
json={"slug": slug, "nom": "Personnel", "perimetre": "personnel"},
)
assert resp.status_code == 200, resp.text
resp = client.post(
f"/api/coffres/{slug}/secrets",
json={
"nom": nom,
"type": "token",
"service": "Gitea",
"valeur": valeur,
},
)
assert resp.status_code == 200, resp.text
return resp.json()
def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
assert resp.status_code == 200, resp.text
consommateur = resp.json()
jeton = consommateur["jeton"]
resp = client.post(
f"/api/coffres/{slug}/octrois",
json={
"cible_type": "consommateur",
"cible_id": consommateur["id"],
"secret_nom": nom,
},
)
assert resp.status_code == 200, resp.text
return consommateur, jeton, resp.json()
def _distribuer(client, jeton, reference="coffre:personnel/gitea-token",
contexte_type="mission", contexte_id="mission-test-1"):
return client.post(
"/api/coffres/distribuer",
json={
"reference": reference,
"contexte_type": contexte_type,
"contexte_id": contexte_id,
},
headers={"Authorization": f"Bearer {jeton}"},
)
def _fichiers_sans_valeur(tmp_path, valeur=VALEUR_TEST):
for path in tmp_path.iterdir():
if path.is_file() and path.suffix in (".json", ".log"):
assert valeur not in path.read_text(encoding="utf-8"), path.name
# ---------------------------------------------------------------------------
# Crypto (Phase 1a)
# ---------------------------------------------------------------------------
class TestCrypto:
def test_aller_retour_aes_gcm(self):
kek = bytes(range(32))
dek = generate_dek()
wrapped = wrap_dek(dek, kek, "coffre-test")
assert unwrap_dek(wrapped, kek, "coffre-test") == dek
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
assert decrypt_secret_value(dek, "secret-1", 1, chiffre["nonce"], chiffre["ciphertext"]) == VALEUR_TEST
def test_tag_invalide_refuse(self):
dek = generate_dek()
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
alt = bytearray(base64.b64decode(chiffre["ciphertext"]))
alt[-1] ^= 1
chiffre_alt = {
"nonce": chiffre["nonce"],
"ciphertext": base64.b64encode(bytes(alt)).decode("ascii"),
}
with pytest.raises(TagInvalideError):
decrypt_secret_value(dek, "secret-1", 1, chiffre_alt["nonce"], chiffre_alt["ciphertext"])
def test_aad_incorrect_refuse(self):
dek = generate_dek()
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
with pytest.raises(TagInvalideError):
decrypt_secret_value(dek, "secret-2", 1, chiffre["nonce"], chiffre["ciphertext"])
def test_parse_kek_invalide(self, monkeypatch):
with pytest.raises(KEKInvalideError):
parse_kek("trop-court")
assert len(parse_kek(generate_kek_b64())) == 32
def test_kek_absente(self, tmp_path, monkeypatch):
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
with pytest.raises(KEKAbsenteError):
load_kek()
def test_kek_fichier_0600_ok_et_trop_permissif_refuse(self, tmp_path, monkeypatch):
kek_file = tmp_path / "kek.txt"
kek_file.write_text(generate_kek_b64(), encoding="utf-8")
os.chmod(kek_file, 0o600)
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
monkeypatch.setenv("OBSIGATE_KEK_FILE", str(kek_file))
assert len(load_kek()) == 32
os.chmod(kek_file, 0o644)
with pytest.raises(KEKInvalideError):
load_kek()
# ---------------------------------------------------------------------------
# Store (Phase 1a)
# ---------------------------------------------------------------------------
class TestStore:
def test_roundtrip_et_aucun_clair_au_repos(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST, service="Gitea")
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
_fichiers_sans_valeur(env_coffre)
def test_rotation_secret(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
secret = rotate_secret(coffre, "gitea-token", VALEUR_TEST_2)
assert secret["version_courante"] == 2
assert distribuer_valeur(coffre, secret) == VALEUR_TEST_2
def test_rotation_dek_puis_kek(self, env_coffre, monkeypatch):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
rotate_dek("personnel")
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
nouvelle_kek = generate_kek_b64()
assert rotate_kek(nouvelle_kek) == 1
monkeypatch.setenv("OBSIGATE_KEK", nouvelle_kek)
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
def test_mode_degrade_sans_kek(self, env_coffre, monkeypatch):
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
coffre = create_coffre("personnel", "Personnel", "bruno")
assert coffre["dek_chiffree"] is None
with pytest.raises(KEKAbsenteError):
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
def test_validation_et_doublons(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
with pytest.raises(ValidationError):
create_coffre("MAUVAIS SLUG !!", "X", "bruno")
with pytest.raises(CoffreExisteError):
create_coffre("personnel", "Autre", "bruno")
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
with pytest.raises(ValidationError):
create_secret(
coffre, "autre-secret", "token", VALEUR_TEST,
note="api-key: ceci-ressemble-a-un-secret",
)
def test_secret_expire_refuse_a_la_distribution(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
secret = create_secret(
coffre, "vieux-jeton", "token", VALEUR_TEST, expiration=hier
)
with pytest.raises(SecretExpireError):
distribuer_valeur(coffre, secret)
# ---------------------------------------------------------------------------
# API et autorisations (Phase 1b)
# ---------------------------------------------------------------------------
class TestApiAutorisations:
def test_flag_desactive_repond_503(self, client, monkeypatch):
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
assert client.get("/api/coffres").status_code == 503
def test_lectures_metadonnees_seules(self, client, env_coffre):
_creer_coffre_secret(client)
resp = client.get("/api/coffres/personnel/secrets")
assert resp.status_code == 200
corps = resp.text
assert VALEUR_TEST not in corps
assert "ciphertext" not in corps
assert "nonce" not in corps
item = resp.json()[0]
assert item["has_value"] is True
assert item["reference"] == "coffre:personnel/gitea-token"
assert "valeur" not in item
def test_admin_ne_voit_pas_le_coffre_personnel_d_autrui(self, client):
_creer_coffre_secret(client) # propriétaire : bruno (admin de test)
_connecter(client, "alice", role="user")
assert client.get("/api/coffres/personnel").status_code == 403
assert client.get("/api/coffres/personnel/secrets").status_code == 403
# Même un admin avec vaults=["*"] n'a aucun droit sur un coffre
# personnel dont il n'est pas propriétaire.
_connecter(client, "admin2", role="admin", vaults=["*"])
assert client.get("/api/coffres/personnel").status_code == 403
def test_acl_etoile_ne_donne_aucun_droit_coffre(self, client):
_creer_coffre_secret(client)
_connecter(client, "mallory", role="user", vaults=["*"])
assert client.get("/api/coffres/personnel/secrets").status_code == 403
def test_coffre_instance_reserve_admin(self, client):
_connecter(client, "bruno", role="user")
resp = client.post(
"/api/coffres",
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
)
assert resp.status_code == 403
_connecter(client, "bruno", role="admin")
resp = client.post(
"/api/coffres",
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
)
assert resp.status_code == 200
_connecter(client, "alice", role="user")
assert client.get("/api/coffres/instance").status_code == 403
def test_creation_secret_sans_kek_repond_503(self, client, monkeypatch):
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
resp = client.post("/api/coffres", json={"slug": "personnel", "nom": "Personnel"})
assert resp.status_code == 200 # métadonnées créées en mode dégradé
resp = client.post(
"/api/coffres/personnel/secrets",
json={"nom": "gitea-token", "type": "token", "valeur": VALEUR_TEST},
)
assert resp.status_code == 503
assert resp.json()["detail"] == "kek_absente"
class TestDistribution:
def test_distribution_ok_puis_revoquee_puis_consommateur_revoque(self, client, env_coffre):
_creer_coffre_secret(client)
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
resp = _distribuer(client, jeton)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
assert resp.json()["version"] == 1
# Le jeton complet n'est jamais relisté, ni son hash (seul le
# préfixe d'affichage, non exploitable, apparaît).
liste = client.get("/api/coffres/consommateurs").json()
assert jeton not in json.dumps(liste)
assert "jeton_hash" not in json.dumps(liste)
assert all(set(item.keys()) == {"id", "nom", "jeton_prefixe", "statut", "cree_par", "cree_le"} for item in liste)
# Révocation de l'octroi : refusé dès le prochain appel.
resp = client.delete(f"/api/coffres/octrois/{octroi['id']}")
assert resp.status_code == 200
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_revoque"
# Révocation du consommateur : tous ses octrois tombent.
resp = client.post(
"/api/coffres/personnel/octrois",
json={"cible_type": "consommateur", "cible_id": consommateur["id"], "secret_nom": "gitea-token"},
)
assert resp.status_code == 200
resp = client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
assert resp.status_code == 200
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "consommateur_revoque"
def test_contexte_manquant_refuse(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.post(
"/api/coffres/distribuer",
json={"reference": "coffre:personnel/gitea-token"},
headers={"Authorization": f"Bearer {jeton}"},
)
assert resp.status_code == 400
assert resp.json()["detail"] == "contexte_manquant"
def test_session_utilisateur_ne_distribue_pas(self, client):
_creer_coffre_secret(client)
_creer_consommateur_octroi(client)
resp = _distribuer(client, "jeton-session-utilisateur-fictif")
assert resp.status_code == 401
assert resp.json()["detail"] == "consommateur_invalide"
def test_octroi_absent_et_secret_expire(self, client):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/consommateurs", json={"nom": "sans-octroi"})
jeton = resp.json()["jeton"]
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_absent"
# Secret expiré : 410, même avec un octroi actif.
_connecter(client, "bruno", role="admin")
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
resp = client.post(
"/api/coffres/personnel/secrets",
json={"nom": "vieux-jeton", "type": "token", "valeur": VALEUR_TEST, "expiration": hier},
)
assert resp.status_code == 200
_, jeton2, _ = _creer_consommateur_octroi(client, nom="vieux-jeton")
resp = _distribuer(client, jeton2, reference="coffre:personnel/vieux-jeton")
assert resp.status_code == 410
assert resp.json()["detail"] == "secret_expire"
def test_rotation_change_la_valeur_distribuee(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.post(
"/api/coffres/personnel/secrets/gitea-token/rotation",
json={"valeur": VALEUR_TEST_2},
)
assert resp.status_code == 200
assert resp.json()["version"] == 2
resp = _distribuer(client, jeton)
assert resp.json()["valeur"] == VALEUR_TEST_2
def test_suppression_casse_les_octrois(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.delete("/api/coffres/personnel/secrets/gitea-token")
assert resp.status_code == 200
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_absent"
def test_octroi_par_defaut_90_jours(self, client):
_creer_coffre_secret(client)
_, _, octroi = _creer_consommateur_octroi(client)
expiration = datetime.fromisoformat(octroi["expiration"])
delta = expiration - datetime.now(timezone.utc)
assert 89 <= delta.days <= 91
def test_audit_sans_valeur_et_journal(self, client, env_coffre):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
assert _distribuer(client, jeton).status_code == 200
_fichiers_sans_valeur(env_coffre)
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
assert "distribution_ok" in audit
assert VALEUR_TEST not in audit
resp = client.get("/api/coffres/personnel/journal")
assert resp.status_code == 200
actions = [e["action"] for e in resp.json()]
assert "secret_cree" in actions
assert "distribution_ok" in actions
assert "valeur" not in json.dumps(resp.json())
+37 -1
View File
@@ -100,9 +100,28 @@ def test_panneau_console_surimpression_222():
def test_csp_ouvre_le_service_d_agents_218():
# Le panneau parle au service d'agents : la CSP doit nommer son
# hôte en connect-src, sans ouvrir de générique supplémentaire.
# #223 : l'origine n'est plus un littéral dans main.py — elle est
# dérivée de la configuration (backend.draco_config.agent_origine,
# testée via la config dans tests/test_security_headers.py).
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
ligne = next(l for l in main.splitlines() if "connect-src" in l)
assert "https://agent.dracodev.net" in ligne
assert "agent_origine" in main
assert "{agent}" in ligne
def test_urls_ecosysteme_via_injection_hote_223():
# #223 : panneau Spark, liens TikTak et onglets des apps lisent
# window.DRACO_URLS (injecté par l'hôte/backend depuis sa config) ;
# les littéraux restants ne sont que les défauts officiels en repli.
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "window.DRACO_URLS" in panneau
assert "DRACO_URLS.agent" in panneau
liens = (FRONTEND / "draco-liens.js").read_text(encoding="utf-8")
assert "window.DRACO_URLS" in liens
assert "baseTikTak()" in liens
html = _index()
assert "window.DRACO_URLS" in html
assert "DRACO_URLS[a.dataset.app]" in html
def test_rail_mobile_vers_le_vrai_hamburger():
@@ -110,3 +129,20 @@ def test_rail_mobile_vers_le_vrai_hamburger():
assert 'matchMedia("(max-width: 768px)")' in html
assert '"hamburger-btn"' in html
assert '"sidebar-toggle-btn"' in html
def test_panneau_version_service_affichee_225():
# #225 : le pied du panneau Spark (mode sidebar) affiche la
# version du service d'agents, comme la console pleine page.
# La version est lue en direct dans la santé du service
# (GET /healthz de l'origine injectée par l'hôte), à l'ouverture
# du panneau ; jamais un littéral de version dans l'asset, et
# source injoignable = ligne laissée masquée (rien d'affiché).
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "draco-panneau-version" in panneau
assert "chargerVersion" in panneau
assert 'API + "/healthz"' in panneau
assert '"Service d\'agents v" + versionConnue' in panneau
# La ligne naît masquée et n'est démasquée qu'avec une version lue.
assert 'class="draco-panneau-version"' in panneau
assert "versionEl.hidden = false" in panneau
+184
View File
@@ -0,0 +1,184 @@
"""URLs publiques de l'écosystème par configuration — #223 (dev/prod).
Règle : aucun nom public de l'écosystème n'est figé dans le code
vivant. Défauts = noms **officiels** (production future) ; le dev
déclare ses noms en ``-dev`` dans son seul ``.env``
(``DRACO_*_URL`` / ``AGENT_EVENEMENTS_URL``). On vérifie ici la
config centralisée, la dérivation de l'URL d'événements du journal,
la reconnaissance des liens profonds des deux environnements et la
reconstruction des URL d'affichage des références croisées.
"""
import pytest
from backend import draco_config
@pytest.fixture()
def env_propre(monkeypatch):
"""Aucune variable d'URL d'écosystème posée (défauts officiels)."""
for nom in (
"DRACO_AGENT_URL", "DRACO_OBSIGATE_URL", "DRACO_FLOWDECK_URL",
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_GITEA_URL",
"AGENT_EVENEMENTS_URL",
):
monkeypatch.delenv(nom, raising=False)
return monkeypatch
def test_defauts_officiels_sans_dev(env_propre):
assert draco_config.agent_url() == "https://agent.dracodev.net"
assert draco_config.app_url("tiktak") == "https://tiktak.dracodev.net"
assert draco_config.app_url("obsigate") == "https://og.dracodev.net"
assert draco_config.app_url("flowdeck") == "https://flowdeck.dracodev.net"
assert draco_config.app_url("kanbante") == "https://kanbante.dracodev.net"
assert draco_config.app_url("gitea") == "https://git.dracodev.net"
for url in draco_config.urls_publiques().values():
assert "-dev" not in url
def test_agent_derive_des_evenements(env_propre):
"""Sans DRACO_AGENT_URL, l'origine suit AGENT_EVENEMENTS_URL —
la configuration réelle du poste (dev inclus)."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
assert draco_config.agent_url() == "https://agent-dev.dracodev.net"
assert draco_config.agent_origine() == "https://agent-dev.dracodev.net"
def test_agent_explicite_prime(env_propre):
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
env_propre.setenv("DRACO_AGENT_URL", "https://agent.example.net/")
assert draco_config.agent_url() == "https://agent.example.net"
# ------------------------------------------------- BUG-120 : dérivation sœurs
def test_soeurs_derivees_depuis_agent_dev(env_propre):
"""BUG-120 : avec **seul** ``AGENT_EVENEMENTS_URL`` en ``-dev``
posé (aucune ``DRACO_<APP>_URL``), les URL sœurs servies suivent
l'environnement de l'agent ; Gitea ne bouge jamais."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
urls = draco_config.urls_publiques()
assert urls["agent"] == "https://agent-dev.dracodev.net"
assert urls["obsigate"] == "https://og-dev.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
assert urls["gitea"] == "https://git.dracodev.net"
def test_soeurs_derivees_depuis_draco_agent_url(env_propre):
"""BUG-120 : la dérivation lit l'agent via ``agent_url()`` — un
``DRACO_AGENT_URL`` en ``-dev`` suffit aussi."""
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
assert draco_config.app_url("tiktak") == "https://tiktak-dev.dracodev.net"
assert draco_config.app_url("obsigate") == "https://og-dev.dracodev.net"
def test_soeur_explicite_prime_sur_derivation(env_propre):
"""BUG-120 : une ``DRACO_<APP>_URL`` explicite gagne toujours sur
la dérivation d'environnement ; les autres sœurs dérivent."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
env_propre.setenv("DRACO_TIKTAK_URL", "https://taches.example.net/")
assert draco_config.app_url("tiktak") == "https://taches.example.net"
assert draco_config.app_url("flowdeck") == "https://flowdeck-dev.dracodev.net"
assert draco_config.app_url("kanbante") == "https://kanbante-dev.dracodev.net"
@pytest.mark.parametrize("evenements", [
"https://agent.example.net/api/v1/evenements", # autre domaine
"https://agent-dev.example.com/api/v1/evenements", # suffixe hors dracodev.net
"http://deepin.lab.home:8790/api/v1/evenements", # nom LAN + port
"http://192.168.30.101:8790/api/v1/evenements", # IP + port
"https://agent-dev.dracodev.net:8790/api/v1/evenements", # port exotique
])
def test_agent_hors_motif_aucune_derivation(env_propre, evenements):
"""BUG-120 : hôte d'agent hors motif ``agent<SUFFIXE>.dracodev.net``
→ **aucune dérivation** (jamais de lien deviné), défauts officiels."""
env_propre.setenv("AGENT_EVENEMENTS_URL", evenements)
urls = draco_config.urls_publiques()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
assert urls["tiktak"] == "https://tiktak.dracodev.net"
assert urls["kanbante"] == "https://kanbante.dracodev.net"
assert urls["gitea"] == "https://git.dracodev.net"
def test_agent_prod_suffixe_vide_defauts_officiels(env_propre):
"""BUG-120 : hôte d'agent prod (suffixe vide) → les sœurs dérivées
sont exactement les défauts officiels."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent.dracodev.net/api/v1/evenements")
for app, defaut in draco_config.DEFAUTS.items():
assert draco_config.app_url(app) == defaut
def test_journal_url_suit_la_config(env_propre):
from backend import agent_journal
assert agent_journal._url() == "https://agent.dracodev.net/api/v1/evenements"
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
assert agent_journal._url() == "https://agent-dev.dracodev.net/api/v1/evenements"
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://autre.example.net/api/v1/evenements")
assert agent_journal._url() == "https://autre.example.net/api/v1/evenements"
# ------------------------------------------------------------ liens profonds
def test_parse_hotes_configures(env_propre):
"""Un lien collé depuis l'environnement dev (hôtes déclarés dans
son .env) est reconnu, comme un lien officiel."""
from backend.external_refs import parse_deep_link
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
env_propre.setenv("DRACO_OBSIGATE_URL", "https://og-dev.dracodev.net")
assert parse_deep_link("https://tiktak-dev.dracodev.net/?task=5") == (
"tiktak", "task", "5", "TikTak · tâche 5")
assert parse_deep_link("https://og-dev.dracodev.net/#file=v:Dossier/Note.md") == (
"obsigate", "note", "v:Dossier/Note.md", "ObsiGate · Note.md")
# L'officiel reste reconnu en parallèle (lien venu de la prod).
assert parse_deep_link("https://tiktak.dracodev.net/?task=5") is not None
def test_url_affichage_reconstruite_depuis_config(env_propre, tmp_path):
"""#223 : la référence stockée garde son URL absolue d'origine
(aucune migration), mais l'URL **servie** est reconstruite depuis
la config courante."""
from backend.external_refs import add_ref, build_deep_link, list_refs
env_propre.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
assert build_deep_link("tiktak", "task", "42") == \
"https://tiktak.dracodev.net/?task=42"
assert build_deep_link("kanbante", "card", "Projets/obsigate#7") == \
"https://git.dracodev.net/Projets/obsigate/issues/7"
assert build_deep_link("obsigate", "note", "v:Dossier/Note.md") == \
"https://og.dracodev.net/#file=v:Dossier%2FNote.md"
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
ref = add_ref("alice", "Vault", "N.md", "tiktak", "task", "42",
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
assert ref["target_url"] == "https://tiktak.dracodev.net/?task=42" # stocké tel quel
servies = list_refs("alice", "Vault", "N.md")
assert servies[0]["target_url"] == "https://tiktak-dev.dracodev.net/?task=42"
# ------------------------------------------------------------ injection hôte
def test_index_injecte_draco_urls(client, env_propre):
"""L'hôte sert index.html avec window.DRACO_URLS issu de sa config.
BUG-120 : l'agent en ``-dev`` entraîne les sœurs en ``-dev``
(dérivation d'environnement) — avant, ``tiktak`` restait en prod.
"""
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
resp = client.get("/")
assert resp.status_code == 200
assert "window.DRACO_URLS" in resp.text
assert '"agent": "https://agent-dev.dracodev.net"' in resp.text
assert '"tiktak": "https://tiktak-dev.dracodev.net"' in resp.text
assert '"gitea": "https://git.dracodev.net"' in resp.text
+56
View File
@@ -72,3 +72,59 @@ def test_security_headers_present(client):
csp = resp.headers.get("content-security-policy", "")
assert "object-src 'none'" in csp
assert "frame-ancestors 'self'" in csp
def _csp_directives(csp: str) -> dict[str, list[str]]:
"""Découpe une politique CSP en ``{directive: [sources]}``."""
directives: dict[str, list[str]] = {}
for part in csp.split(";"):
tokens = part.split()
if tokens:
directives[tokens[0]] = tokens[1:]
return directives
def test_csp_frame_src_allows_agent_console(client, monkeypatch):
"""BUG-119 : le « mode console » du panneau Spark charge la console du
service d'agents (``https://agent.dracodev.net``) dans une iframe —
``frame-src`` doit autoriser cette origine, sinon le navigateur bloque
le chargement (« This content is blocked »). Les sources historiques
(``'self'`` et ``blob:``, visualiseur PDF) restent en place et aucune
autre origine n'est admise.
#223 : la configuration est neutralisée ici (défaut officiel) —
l'origine n'est plus figée dans le code, voir le test via config
ci-dessous."""
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
resp = client.get("/api/health")
csp = resp.headers.get("content-security-policy", "")
directives = _csp_directives(csp)
assert directives.get("frame-src") == [
"'self'",
"blob:",
"https://agent.dracodev.net",
]
assert "https://agent.dracodev.net" in directives.get("connect-src", [])
def test_csp_agent_origine_via_config(client, monkeypatch):
"""#223 : ``frame-src``/``connect-src`` suivent l'origine de l'agent
**configurée** — dérivée d'``AGENT_EVENEMENTS_URL`` (config réelle
du poste, noms en ``-dev`` en dev), ou de ``DRACO_AGENT_URL`` qui
prime. Aucun nom d'environnement n'est codé en dur."""
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
resp = client.get("/api/health")
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
assert directives.get("frame-src") == [
"'self'", "blob:", "https://agent-dev.dracodev.net"]
assert "https://agent-dev.dracodev.net" in directives.get("connect-src", [])
assert "https://agent.dracodev.net" not in directives.get("connect-src", [])
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.example.net")
resp = client.get("/api/health")
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
assert directives.get("frame-src") == [
"'self'", "blob:", "https://agent.example.net"]