Compare commits

...
Author SHA1 Message Date
bruno 2a1780b43d docs: commentaire 15 -> 16 themes integres (meme compteur obsolete que test_themes.py)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m11s
CI / test (pull_request) Failing after 5m8s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:25:38 -04:00
bruno be05ec99b5 fix(tests): test_theme_count 15 -> 16 (theme spark ajoute en 2.80.2) 2026-10-28 08:25:35 -04:00
bruno 3a42bc034d fix(tests): declare le tag OpenAPI 'Coffres Secrets' (test_used_tags_are_declared) 2026-10-28 08:25:32 -04:00
bruno 0ca14739f9 Merge pull request 'fix(lint): tri des imports dans backend/routers/coffres.py (I001)' (#32) from fix/ruff-coffres-imports into main
CI / security (push) Successful in 1m56s
CI / lint (push) Successful in 3m14s
CI / test (push) Failing after 5m5s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 08:16:47 -04:00
bruno d9192ef355 fix(lint): tri des imports dans backend/routers/coffres.py (I001)
CI / lint (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m59s
2026-10-28 08:13:35 -04:00
bruno 4c9bfd576c Merge pull request 'chore(release): version 2.81.0 (Coffre Secrets Phases 2-5)' (#31) from fix/version-2.81.0 into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m50s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:16:49 -04:00
bruno 20898cade7 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5)
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-28 00:16:25 -04:00
bruno 2c12639499 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:21 -04:00
bruno 7d2b2067ef chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:19 -04:00
bruno 0b52e6ee86 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:16 -04:00
bruno cafac69008 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:12 -04:00
bruno 871875b8e5 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:10 -04:00
bruno e5ae3e48d6 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:08 -04:00
bruno 76de3dd75e chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:05 -04:00
bruno 687427381d chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:03 -04:00
bruno e418d87802 Merge pull request '[Coffre Secrets] Phase 5 — Documentation de deploiement et mise en service' (#30) from feat/coffre-phase5-deploiement into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:15:09 -04:00
bruno a1f3690085 Merge pull request '[Coffre Secrets] Phase 2 — Migration api_keys.json, sauvegarde/restauration KEK, contrat distribution, consommateurs + correctif UI modale' (#29) from feat/coffre-phase2 into main
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-28 00:15:06 -04:00
bruno 0a7bed10bf Phase 5 (issue #23) : correction numerotation etape lecteurs internes (4, pas 5)
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) Failing after 0s
2026-10-28 00:11:20 -04:00
bruno 32996ce9aa Phase 5 (issue #23) : deploiement et mise en service du coffre Secrets 2026-10-28 00:10:51 -04:00
bruno b42265b41e Phase 2 coffre: correctif modale (max-height viewport + scroll interne)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 1m46s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:45:44 -04:00
bruno 0fc7654f6c Phase 2 coffre: cles i18n import (en) 2026-10-27 23:45:38 -04:00
bruno effad97472 Phase 2 coffre: cles i18n import (fr) 2026-10-27 23:45:33 -04:00
bruno 53d98bfffe Phase 2 coffre: variables OBSIGATE_SECRETS_ENABLED/KEK documentees (sans valeur) 2026-10-27 23:45:20 -04:00
bruno 62aa6079be Phases 3-4 coffre: contrat de distribution Spark et consommateurs 2026-10-27 23:45:16 -04:00
bruno 7d48f65ff9 Phase 2 coffre: documentation sauvegarde/restauration KEK 2026-10-27 23:45:13 -04:00
bruno 3acadf6ad7 Phase 2 coffre: bouton Importer l'existant (import puis purge confirmee) 2026-10-27 23:45:10 -04:00
bruno 5492f99f06 Phase 2 coffre: tests migration, restauration vierge, Spark, consommateurs 2026-10-27 23:45:05 -04:00
bruno 71221e055b Phase 2 coffre: migration, consommateur+octrois, contexte Spark imbrique 2026-10-27 23:44:58 -04:00
bruno 86330e8f65 Phase 2 coffre: module migration api_keys.json -> coffre d'instance (import + purge confirmee) 2026-10-27 23:44:43 -04:00
bruno f3b507b8d9 Merge pull request '[Theme] Nouveau theme Spark (dark/light, identique a la Console Spark) et defaut = Spark dark' (#28) from feat/theme-spark into main
CI / security (push) Failing after 0s
CI / lint (push) Successful in 3m6s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:25:10 -04:00
bruno c01c7e862d Merge pull request '[fix] Mypy Coffre Secrets (3 erreurs) + version 2.80.2' (#27) from fix/mypy-coffre-2.80.2 into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:24:44 -04:00
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 070add4e7b [Thème Spark] Anti-FOUC admin aligné sur fond/texte Spark
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m10s
CI / test (pull_request) Failing after 5m22s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:10:14 -04:00
bruno 847b3225b2 [Thème Spark] Splash et meta theme-color alignés sur Spark dark 2026-10-27 22:10:11 -04:00
bruno 3ae5c7efac [Thème Spark] Bloc :root de secours aligné sur la palette dark Spark 2026-10-27 22:10:07 -04:00
bruno 4966c27548 [Thème Spark] Ajout du thème 'spark' (dark/light, 39 variables) et défaut spark/dark 2026-10-27 22:10:04 -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
53 changed files with 5495 additions and 139 deletions
+10
View File
@@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation> # DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net # DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée> # DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ──
# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) :
# OBSIGATE_SECRETS_ENABLED=true
# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par
# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note :
# OBSIGATE_KEK=<kek-base64-32-octets>
# … ou fichier 0600, hors dépôt et hors arborescence des vaults :
# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt
# Aucune autre variable ne doit contenir une valeur de secret du coffre.
+46 -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). 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 > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.78.2**. > [Unreleased](#unreleased). La dernière version livrée est **2.81.0**.
--- ---
@@ -14,6 +14,51 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.81.0] — 2026-10-28
---
## [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 ## [2.78.2] — 2026-10-26
### Corrigé ### Corrigé
+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. **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.78.2-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.81.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![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 ## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.78.2). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
--- ---
*Projet : ObsiGate | Version : 2.78.2 | Dernière mise à jour : Septembre 2026* *Projet : ObsiGate | Version : 2.81.0 | 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. **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.78.2-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.81.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![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 ## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.78.2). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
--- ---
*Project: ObsiGate | Version: 2.78.2 | Last updated: September 2026* *Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.78.2 2.81.0
+2 -2
View File
@@ -10,7 +10,7 @@ import logging
import os import os
import shutil import shutil
import time import time
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
"disk_used_gb": disk_used_gb, "disk_used_gb": disk_used_gb,
"disk_total_gb": disk_total_gb, "disk_total_gb": disk_total_gb,
"uptime_seconds": int(_server_uptime_seconds()), "uptime_seconds": int(_server_uptime_seconds()),
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
} }
+41 -6
View File
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
import json import json
import logging import logging
import os import os
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
logger = logging.getLogger("obsigate.audit") logger = logging.getLogger("obsigate.audit")
@@ -54,7 +54,7 @@ def log_file_save(
): ):
"""Log a file save (PUT) operation.""" """Log a file save (PUT) operation."""
_write_entry({ _write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"action": "file_save", "action": "file_save",
"username": username, "username": username,
"ip": ip or "unknown", "ip": ip or "unknown",
@@ -72,7 +72,7 @@ def log_file_delete(
): ):
"""Log a file delete operation.""" """Log a file delete operation."""
_write_entry({ _write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"action": "file_delete", "action": "file_delete",
"username": username, "username": username,
"ip": ip or "unknown", "ip": ip or "unknown",
@@ -88,7 +88,7 @@ def log_config_change(
): ):
"""Log a configuration change.""" """Log a configuration change."""
_write_entry({ _write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"action": "config_change", "action": "config_change",
"username": username, "username": username,
"ip": ip or "unknown", "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): def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
"""Log a vault addition.""" """Log a vault addition."""
_write_entry({ _write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"action": "vault_add", "action": "vault_add",
"username": username, "username": username,
"ip": ip or "unknown", "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): def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
"""Log a vault removal.""" """Log a vault removal."""
_write_entry({ _write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"action": "vault_remove", "action": "vault_remove",
"username": username, "username": username,
"ip": ip or "unknown", "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: def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries. """Read the most recent audit log entries.
+3 -2
View File
@@ -7,6 +7,7 @@ import binascii
import logging import logging
import os import os
import re import re
from datetime import UTC
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator 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). 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 .user_store import get_user, update_user
from .webauthn_mfa import complete_registration, resolve_relying_party 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}") logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
raise HTTPException(400, "Validation du credential WebAuthn échoué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 = list(user.get("webauthn_credentials", []))
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]] creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
creds.append(record) creds.append(record)
+7 -7
View File
@@ -8,7 +8,7 @@ import logging
import shutil import shutil
import threading import threading
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from .password import hash_password from .password import hash_password
@@ -97,8 +97,8 @@ def create_user(
"active": True, "active": True,
"language": "fr", # default UI language "language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113) "avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(UTC).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(), "password_changed_at": datetime.now(UTC).timestamp(),
"last_login": None, "last_login": None,
"failed_attempts": 0, "failed_attempts": 0,
"locked_until": None, "locked_until": None,
@@ -126,7 +126,7 @@ def update_user(username: str, updates: dict) -> dict:
if "password" in safe_updates: if "password" in safe_updates:
safe_updates["password_hash"] = hash_password(safe_updates.pop("password")) safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
# BUG-028: invalidate every token issued before this change. # 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) data["users"][username].update(safe_updates)
_write(data) _write(data)
@@ -148,7 +148,7 @@ def delete_user(username: str):
def record_login_success(username: str): def record_login_success(username: str):
"""Record a successful login: update last_login, reset failed attempts.""" """Record a successful login: update last_login, reset failed attempts."""
update_user(username, { update_user(username, {
"last_login": datetime.now(timezone.utc).isoformat(), "last_login": datetime.now(UTC).isoformat(),
"failed_attempts": 0, "failed_attempts": 0,
"locked_until": None, "locked_until": None,
}) })
@@ -171,7 +171,7 @@ def record_login_failure(username: str) -> int:
# Lock after 5 failed attempts (15 minutes) # Lock after 5 failed attempts (15 minutes)
if attempts >= 5: if attempts >= 5:
locked_until = ( locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15) datetime.now(UTC) + timedelta(minutes=15)
).isoformat() ).isoformat()
updates["locked_until"] = locked_until updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts") 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"): if not user or not user.get("locked_until"):
return False return False
locked_until = datetime.fromisoformat(user["locked_until"]) locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now(timezone.utc) > locked_until: if datetime.now(UTC) > locked_until:
# Lock expired — unlock # Lock expired — unlock
update_user(username, {"locked_until": None, "failed_attempts": 0}) update_user(username, {"locked_until": None, "failed_attempts": 0})
return False return False
+1 -1
View File
@@ -591,7 +591,7 @@ async def api_bookslm_agent(
while True: while True:
try: try:
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25) kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
except asyncio.TimeoutError: except TimeoutError:
if run_task.done(): if run_task.done():
break break
continue 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)
+306
View File
@@ -0,0 +1,306 @@
"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20).
Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le
coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``).
Format source (constaté dans ``backend/ai.py`` et
``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``.
Seules les entrées dont le nom ressemble à un secret sont migrables :
clés IA et clés d'outils. Les valeurs non secrètes (par exemple
``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais
touchées par la purge.
Règles de sécurité, non négociables :
- Aucune fonction de ce module ne renvoie une valeur, ne la journalise,
ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store).
Les aperçus ne contiennent que des noms, des types et ``has_value``.
- L'import ne supprime rien : il crée des secrets dans le coffre et
laisse ``api_keys.json`` intact. La purge est une étape séparée, qui
exige ``confirmer=True`` et ne supprime que les noms listés.
- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est
signalé dans ``deja_existants`` et la valeur du coffre est conservée.
Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` :
les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``)
lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer
par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le
lecteur concerné sur le coffre rend donc la clé inaccessible à ce
lecteur : la purge reste un choix explicite de l'opérateur, confirmé
deux fois côté interface (import, puis purge).
"""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
from backend.coffre_store import (
ValidationError,
create_coffre,
create_secret,
get_coffre,
get_secret,
resolve_data_dir,
)
logger = logging.getLogger("obsigate.coffre_migration")
_lock = threading.RLock()
COFFRE_INTERNE_SLUG = "obsigate-interne"
COFFRE_INTERNE_NOM = "ObsiGate interne"
# (nom dans api_keys.json, type de secret, service d'affichage).
# Miroir des clés lues par backend/ai.py (clés IA) et par
# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea
# est volontairement absente : ce n'est pas un secret.
CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = (
("DEEPSEEK_API_KEY", "api_key", "DeepSeek"),
("OPENROUTER_API_KEY", "api_key", "OpenRouter"),
("GEMINI_API_KEY", "api_key", "Gemini"),
("NVIDIA_API_KEY", "api_key", "NVIDIA"),
("QWENCLOUD_API_KEY", "api_key", "QwenCloud"),
("XIAOMI_API_KEY", "api_key", "Xiaomi"),
("MISTRAL_API_KEY", "api_key", "Mistral"),
("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"),
("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"),
("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"),
("OBSIGATE_EXA_API_KEY", "api_key", "Exa"),
("OBSIGATE_GITEA_TOKEN", "token", "Gitea"),
("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"),
)
_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES}
def _nom_secret(nom_source: str) -> str:
"""Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE)."""
return nom_source.lower()
def _api_keys_path(base: Path) -> Path:
return base / "api_keys.json"
def _read_api_keys(base: Path) -> dict:
path = _api_keys_path(base)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("api_keys.json illisible (%s) : %s", path, exc)
return {}
return data if isinstance(data, dict) else {}
def _write_api_keys(base: Path, data: dict) -> None:
"""Écriture atomique tmp+rename, fichier final en 0600 (best-effort)."""
path = _api_keys_path(base)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_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 lister_candidats(data_dir: str | Path | None = None) -> list:
"""Aperçu de migration : métadonnées seules, jamais de valeur.
Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom
dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur
non vide est présente dans le fichier) et ``deja_existant`` (un
secret de ce nom existe déjà dans le coffre d'instance).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
candidats = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
candidats.append(
{
"nom": nom,
"source": source,
"type": type_secret,
"service": service,
"has_value": isinstance(valeur, str) and bool(valeur.strip()),
"deja_existant": (
get_secret(coffre["id"], nom, base) is not None
if coffre is not None
else False
),
}
)
return candidats
def importer_api_keys(
data_dir: str | Path | None = None, cree_par: str = ""
) -> dict:
"""Importe les entrées avec valeur vers le coffre d'instance.
Ne supprime rien dans ``api_keys.json``. Retourne uniquement des
noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``.
Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre
ne peut pas chiffrer (le routeur les traduit en 503).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
if coffre is None:
coffre = create_coffre(
slug=COFFRE_INTERNE_SLUG,
nom=COFFRE_INTERNE_NOM,
proprietaire=(cree_par or "admin").strip(),
perimetre="instance",
description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres: list[str] = []
deja_existants: list[str] = []
sans_valeur: list[str] = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
if not (isinstance(valeur, str) and valeur.strip()):
sans_valeur.append(nom)
continue
if get_secret(coffre["id"], nom, base) is not None:
deja_existants.append(nom)
continue
create_secret(
coffre,
nom,
type_secret,
valeur,
service=service,
note="Migré depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres.append(nom)
return {
"coffre": COFFRE_INTERNE_SLUG,
"migres": migres,
"deja_existants": deja_existants,
"sans_valeur": sans_valeur,
}
def purger_api_keys(
noms: list[str] | None,
data_dir: str | Path | None = None,
confirmer: bool = False,
) -> dict:
"""Supprime du clair les noms listés, après confirmation explicite.
``confirmer`` doit valoir True, sinon refus (jamais automatique).
Seuls les noms migrables sont acceptés ; un nom inconnu refuse
l'opération entière, pour ne jamais supprimer une clé par erreur.
Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs).
"""
if not confirmer:
raise ValidationError(
"Suppression du clair refusée : confirmation explicite requise"
)
demandes = [(n or "").strip().lower() for n in (noms or [])]
# Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom
# de secret (deepseek_api_key) ; tout le reste refuse l'opération.
noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES}
inconnus = [
n
for n in demandes
if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets
]
if inconnus:
raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}")
sources_a_supprimer = set()
for demande in demandes:
for source, _, _ in CLES_MIGRABLES:
if demande in (source.lower(), _nom_secret(source)):
sources_a_supprimer.add(source)
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
supprimes: list[str] = []
absents: list[str] = []
for source in sorted(sources_a_supprimer):
if source in cles:
del cles[source]
supprimes.append(_nom_secret(source))
else:
absents.append(_nom_secret(source))
_write_api_keys(base, cles)
return {"supprimes": supprimes, "absents": absents}
def creer_consommateur_avec_octrois(
nom: str,
octrois_demandes: list[dict[str, Any]] | None,
cree_par: str,
data_dir: str | Path | None = None,
) -> tuple[dict, str, list, list]:
"""Enregistre un consommateur et ses octrois en une seule opération.
Retourne (consommateur public, jeton à usage unique, octrois créés,
erreurs). Une erreur sur un octroi (coffre ou secret introuvable)
n'annule pas le consommateur ni les autres octrois : elle est
signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre
lui-même n'est jamais modifié par cette opération.
"""
from backend.coffre_store import ( # import local : évite un cycle
IntrouvableError,
create_consommateur,
create_octroi,
)
base = resolve_data_dir(data_dir)
consommateur, jeton = create_consommateur(nom, cree_par, base)
crees: list[dict] = []
erreurs: list[dict] = []
for demande in octrois_demandes or []:
slug = str(demande.get("coffre_slug", "") or "")
secret_nom = str(demande.get("secret_nom", "") or "") or None
try:
coffre = get_coffre(slug, base)
if coffre is None:
raise IntrouvableError(f"Coffre introuvable : {slug}")
secret_id = None
if secret_nom is not None:
secret = get_secret(coffre["id"], secret_nom, base)
if secret is None:
raise IntrouvableError(f"Secret introuvable : {secret_nom}")
secret_id = secret["id"]
octroi = create_octroi(
coffre=coffre,
cible_type="consommateur",
cible_id=consommateur["id"],
secret_id=secret_id,
cree_par=cree_par,
expiration=demande.get("expiration"),
data_dir=base,
)
crees.append(octroi)
except (IntrouvableError, ValidationError) as exc:
erreurs.append(
{
"coffre_slug": slug,
"secret_nom": secret_nom,
"erreur": str(exc),
}
)
return consommateur, jeton, crees, erreurs
+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")}
+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) 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"?> xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr"> <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:title>{html_mod.escape(title)}</dc:title>
<dc:creator>{html_mod.escape(author)}</dc:creator> <dc:creator>{html_mod.escape(author)}</dc:creator>
<dc:language>fr</dc:language> <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> </metadata>
<manifest> <manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/> <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
+1 -1
View File
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
template = loc.get("guide105.export_footer", "") template = loc.get("guide105.export_footer", "")
if "%s" not in template and "{" not in template: if "%s" not in template and "{" not in template:
template = "ObsiGate {version}" 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) return _collapse(template).format(version=_app_version(), date=today)
+4 -4
View File
@@ -5,7 +5,7 @@ import os
import re import re
import threading import threading
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -581,7 +581,7 @@ def _scan_vault(
try: try:
relative = fpath.relative_to(vault_root) relative = fpath.relative_to(vault_root)
stat = fpath.stat() 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 # #86 differential scan: reuse the previous entry when neither
# size nor mtime changed — skips the disk read + parse below. # 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 # Record timestamp of full index rebuild
global _last_full_index_ts 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 # Build attachment index
from backend.attachment_indexer import 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 return None
stat = fpath.stat() 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) # PDF handling — binary, must go through pdf_reader (same as _scan_vault)
tags: list[str] = [] tags: list[str] = []
+2
View File
@@ -530,6 +530,7 @@ from backend.auth.middleware import (
from backend.auth.router import router as auth_router from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_router from backend.bookslm_routes import router as bookslm_router
from backend.routers.backups import router as backups_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 _load_config
from backend.routers.config import router as config_router from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router from backend.routers.conflicts import router as conflicts_router
@@ -564,6 +565,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
app.include_router(config_router) # ROADMAP #85 T7 — Config 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(file_chat_router) # ROADMAP #169 — Chat par fichier
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
+4 -4
View File
@@ -25,7 +25,7 @@ import os
import smtplib import smtplib
import threading import threading
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -182,7 +182,7 @@ def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict
"type": channel_type, "type": channel_type,
"enabled": True, "enabled": True,
"config": normalized, "config": normalized,
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(UTC).isoformat(),
"last_sent_at": None, "last_sent_at": None,
"last_error": None, "last_error": None,
} }
@@ -287,7 +287,7 @@ def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
"event": trigger, "event": trigger,
"title": title, "title": title,
"message": message, "message": message,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"source": "obsigate-notify", "source": "obsigate-notify",
}, },
timeout=10.0, timeout=10.0,
@@ -345,6 +345,6 @@ def _mark_sent(channel_id: str, error: str | None) -> None:
channels = _read_channels() channels = _read_channels()
for channel in channels: for channel in channels:
if channel.get("id") == channel_id: 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 channel["last_error"] = error
_write_channels(channels) _write_channels(channels)
+1
View File
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."}, {"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."}, {"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."}, {"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
{"name": "Coffres Secrets", "description": "Secret vaults: encrypted coffres, secrets, consumers, grants and distribution — metadata only, values are never exposed."},
{"name": "Plugins", "description": "Install, enable and manage user plugins."}, {"name": "Plugins", "description": "Install, enable and manage user plugins."},
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."}, {"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."}, {"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
+2 -2
View File
@@ -6,7 +6,7 @@ import base64
import json import json
import logging import logging
import os import os
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -185,7 +185,7 @@ async def subscribe_push(
"keys": request.subscription.keys, "keys": request.subscription.keys,
"vault": request.vault, "vault": request.vault,
"username": username, "username": username,
"created_at": datetime.now(timezone.utc).isoformat() "created_at": datetime.now(UTC).isoformat()
} }
_push_subscriptions.append(subscription) _push_subscriptions.append(subscription)
+6
View File
@@ -73,3 +73,9 @@ filelock>=4.2.1
# aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée # aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée
# en 1.45.1. # en 1.45.1.
opentelemetry-api>=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 logging
import os import os
import time import time
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -207,7 +207,7 @@ async def api_backups_list(
"file": rel_file, "file": rel_file,
"backup_file": fpath.name, "backup_file": fpath.name,
"timestamp": ts, "timestamp": ts,
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(), "datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
"size": fsize, "size": fsize,
"full_path": str(fpath), "full_path": str(fpath),
}) })
+731
View File
@@ -0,0 +1,731 @@
"""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.
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
d'instance, en deux temps (import, puis purge confirmée), et
enregistrement d'un consommateur avec ses octrois en un appel.
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
d'erreur restent ceux de la Phase 1b (voir
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
l'état ``acces_requis`` côté Spark).
"""
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_migration import (
COFFRE_INTERNE_SLUG,
creer_consommateur_avec_octrois,
importer_api_keys,
lister_candidats,
purger_api_keys,
)
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.post("/api/coffres/consommateurs/avec-octrois")
async def api_create_consommateur_avec_octrois(
body: dict = Body(...), current_user=Depends(require_admin)
):
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
sans annuler le consommateur ni les autres octrois.
"""
_exiger_actives()
try:
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
nom=body.get("nom", ""),
octrois_demandes=body.get("octrois") or [],
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",
)
for octroi in octrois:
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre_octroi["slug"] if coffre_octroi else None,
resultat="ok",
)
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
@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
# ---------------------------------------------------------------------------
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
# L'import ne supprime rien ; la purge exige une confirmation explicite
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
# une valeur, seulement des noms et des comptes.
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
# capturées par le paramètre slug.
# ---------------------------------------------------------------------------
@router.get("/api/coffres/migration/api-keys")
async def api_migration_apercu(current_user=Depends(require_admin)):
_exiger_actives()
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
@router.post("/api/coffres/migration/api-keys/import")
async def api_migration_import(current_user=Depends(require_admin)):
_exiger_actives()
try:
resultat = importer_api_keys(cree_par=_username(current_user))
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
for nom in resultat["migres"]:
_audit(
_username(current_user),
"migration_api_keys",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
@router.post("/api/coffres/migration/api-keys/purger")
async def api_migration_purger(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
if body.get("confirmer") is not True:
raise HTTPException(status_code=400, detail="confirmation_requise")
try:
resultat = purger_api_keys(
noms=body.get("noms") or [], confirmer=True
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for nom in resultat["supprimes"]:
_audit(
_username(current_user),
"migration_api_keys_purge",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
# ---------------------------------------------------------------------------
# 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()
# Phase 3 (Spark) : le contexte peut arriver à plat
# (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
# forme envoyée par le client Spark). Les deux formes sont acceptées.
contexte_type = str(body.get("contexte_type") or "").strip()
contexte_id_raw = body.get("contexte_id")
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
if not contexte_type or not contexte_id:
contexte = body.get("contexte")
if isinstance(contexte, dict):
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
contexte_id_raw = contexte.get("id")
contexte_id = contexte_id or (
"" if contexte_id_raw is None else str(contexte_id_raw).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: try:
msg = await asyncio.wait_for(queue.get(), timeout=30.0) msg = await asyncio.wait_for(queue.get(), timeout=30.0)
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n" yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
except asyncio.TimeoutError: except TimeoutError:
# Keepalive comment # Keepalive comment
yield ": keepalive\n\n" yield ": keepalive\n\n"
except asyncio.CancelledError: except asyncio.CancelledError:
+12 -12
View File
@@ -26,7 +26,7 @@ import logging
import os import os
import threading import threading
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -118,7 +118,7 @@ def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
try: try:
parsed = datetime.fromisoformat(at) parsed = datetime.fromisoformat(at)
if parsed.tzinfo is None: if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc) parsed = parsed.replace(tzinfo=UTC)
except ValueError: except ValueError:
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
return {"kind": kind, "at": parsed.isoformat()} return {"kind": kind, "at": parsed.isoformat()}
@@ -138,7 +138,7 @@ def create_task(
"""Create a scheduled task. Raises ValueError on invalid action/schedule.""" """Create a scheduled task. Raises ValueError on invalid action/schedule."""
validated_action = _validate_action(action) validated_action = _validate_action(action)
validated_schedule = _validate_schedule(schedule) validated_schedule = _validate_schedule(schedule)
now = datetime.now(timezone.utc) now = datetime.now(UTC)
with _lock: with _lock:
tasks = _read_tasks() tasks = _read_tasks()
task = { 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: def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
"""Compute the next due datetime for *task*.""" """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: if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc) now = now.replace(tzinfo=UTC)
schedule = task.get("schedule", {}) schedule = task.get("schedule", {})
kind = schedule.get("kind") kind = schedule.get("kind")
last_run_at = task.get("last_run_at") 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: try:
last = datetime.fromisoformat(str(last_run_at)) last = datetime.fromisoformat(str(last_run_at))
if last.tzinfo is None: if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc) last = last.replace(tzinfo=UTC)
except ValueError: except ValueError:
last = None last = None
if kind == "interval_hours": if kind == "interval_hours":
@@ -233,11 +233,11 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
try: try:
at = datetime.fromisoformat(str(schedule.get("at"))) at = datetime.fromisoformat(str(schedule.get("at")))
if at.tzinfo is None: if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc) at = at.replace(tzinfo=UTC)
except ValueError: except ValueError:
return now return now
if task.get("last_run_at"): 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 at
return now + timedelta(hours=24) 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"} return {"task_id": task_id, "skipped": True, "reason": "disabled"}
try: try:
result = _execute_action(task) 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_status"] = "ok"
task["last_error"] = None task["last_error"] = None
task["run_count"] = int(task.get("run_count", 0)) + 1 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.") broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
return {"task_id": task_id, "ok": True, "result": result} return {"task_id": task_id, "ok": True, "result": result}
except Exception as e: 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_status"] = "error"
task["last_error"] = str(e) task["last_error"] = str(e)
task["run_count"] = int(task.get("run_count", 0)) + 1 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]]: def tick(now: datetime | None = None) -> list[dict[str, Any]]:
"""Run every due task. Returns per-task outcomes (empty when idle).""" """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]] = [] outcomes: list[dict[str, Any]] = []
for task in _read_tasks(): for task in _read_tasks():
if not task.get("enabled", True): if not task.get("enabled", True):
@@ -323,7 +323,7 @@ def tick(now: datetime | None = None) -> list[dict[str, Any]]:
try: try:
next_run = datetime.fromisoformat(str(task.get("next_run_at") or "")) next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
if next_run.tzinfo is None: if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc) next_run = next_run.replace(tzinfo=UTC)
except ValueError: except ValueError:
next_run = compute_next_run(task, now) next_run = compute_next_run(task, now)
if next_run <= now: if next_run <= now:
+3 -3
View File
@@ -14,7 +14,7 @@ import logging
import os import os
import shutil import shutil
import time import time
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any 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): except (ValueError, TypeError):
continue continue
try: try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError) as ts_err: except (OSError, OverflowError, ValueError) as ts_err:
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}") logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
continue continue
@@ -219,7 +219,7 @@ def diff_backup(
status=500, status=500,
details={"vault": vault_name, "path": path, "version": ts}, details={"vault": vault_name, "path": path, "version": ts},
) from e ) 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}" return content, f"{path}@{dt}"
try: try:
+1 -1
View File
@@ -117,7 +117,7 @@ def list_recent(
for v_name, f in recent: for v_name, f in recent:
iso_modified = f.get("modified", "") iso_modified = f.get("modified", "")
try: try:
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00")) mtime_dt = datetime.fromisoformat(iso_modified)
mtime_val = mtime_dt.timestamp() mtime_val = mtime_dt.timestamp()
except Exception: except Exception:
mtime_val = time.time() 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 __future__ import annotations
from datetime import UTC
from pathlib import Path from pathlib import Path
from typing import Any 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]: def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"""Return the direct children of a vault directory (directories first).""" """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.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting 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", "type": "file",
"size": stat.st_size, "size": stat.st_size,
"modified": stat.st_mtime, "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(), "extension": entry.suffix.lower(),
}) })
except PermissionError: except PermissionError:
@@ -156,7 +157,7 @@ def list_all_files(
ServiceError: ``not_found`` (404) if the directory does not exist, ServiceError: ``not_found`` (404) if the directory does not exist,
``permission_denied`` (403) if it cannot be read. ``permission_denied`` (403) if it cannot be read.
""" """
from datetime import datetime, timezone from datetime import datetime
from backend.indexer import IGNORED_DIRS from backend.indexer import IGNORED_DIRS
@@ -215,7 +216,7 @@ def list_all_files(
"vault": vault_name, "vault": vault_name,
"size": stat.st_size, "size": stat.st_size,
"modified": stat.st_mtime, "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 "", "extension": ext.lstrip(".") if ext else "",
"tags": _indexed_tags(vault_name, rel_path), "tags": _indexed_tags(vault_name, rel_path),
} }
+5 -5
View File
@@ -11,7 +11,7 @@ import json
import logging import logging
import secrets import secrets
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
logger = logging.getLogger("obsigate.share") logger = logging.getLogger("obsigate.share")
@@ -58,7 +58,7 @@ def create_share(
expires_at = None expires_at = None
if expires_in_hours: 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 = { share = {
"id": token, "id": token,
@@ -66,7 +66,7 @@ def create_share(
"vault": vault, "vault": vault,
"path": path, "path": path,
"created_by": created_by, "created_by": created_by,
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(UTC).isoformat(),
"expires_at": expires_at, "expires_at": expires_at,
"access_count": 0, "access_count": 0,
"last_accessed": None, "last_accessed": None,
@@ -86,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
return None return None
if share.get("expires_at"): if share.get("expires_at"):
expires = datetime.fromisoformat(share["expires_at"]) expires = datetime.fromisoformat(share["expires_at"])
if datetime.now(timezone.utc) > expires: if datetime.now(UTC) > expires:
return None return None
return share return share
@@ -98,7 +98,7 @@ def record_access(token: str):
share = data["shares"].get(token) share = data["shares"].get(token)
if share: if share:
share["access_count"] = share.get("access_count", 0) + 1 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) _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 __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import Any from typing import Any
from backend.audit import _write_entry from backend.audit import _write_entry
@@ -41,7 +41,7 @@ def log_tool_call(
) -> None: ) -> None:
"""Append an ``ai_tool_call`` entry to the audit log.""" """Append an ``ai_tool_call`` entry to the audit log."""
_write_entry({ _write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"action": "ai_tool_call", "action": "ai_tool_call",
"username": username, "username": username,
"ip": ip or "unknown", "ip": ip or "unknown",
+1 -1
View File
@@ -244,7 +244,7 @@ class VaultWatcher:
key = event.get('dest') or event['src'] key = event.get('dest') or event['src']
pending[key] = event pending[key] = event
except asyncio.TimeoutError: except TimeoutError:
if pending: if pending:
events_to_process = list(pending.values()) events_to_process = list(pending.values())
pending.clear() pending.clear()
+4 -4
View File
@@ -31,7 +31,7 @@ import os
import socket import socket
import threading import threading
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -275,7 +275,7 @@ def create_webhook(
"url": url, "url": url,
"events": [e for e in events if e in VALID_EVENTS], "events": [e for e in events if e in VALID_EVENTS],
"enabled": True, "enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(UTC).isoformat(),
"last_fired_at": None, "last_fired_at": None,
# #198 : filtres optionnels (None = pas de filtre, reçoit tout). # #198 : filtres optionnels (None = pas de filtre, reçoit tout).
"vault": _normalize_vault_filter(vault), "vault": _normalize_vault_filter(vault),
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
payload = { payload = {
"event": event_type, "event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(UTC).isoformat(),
"data": data, "data": data,
} }
body = json.dumps(payload, default=str) 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})") logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else: else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})") 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: except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}") logger.warning(f"Webhook '{wh['name']}' error: {e}")
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.78.2" version = "2.81.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.78.2" version = "2.81.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.78.2", "version": "2.81.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+156
View File
@@ -0,0 +1,156 @@
# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4)
Référence du contrat pour les clients serveur-à-serveur : Spark
(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne
duplique aucune logique de chiffrement : le chiffrement reste
entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM).
## 1. Endpoint
```
POST /api/coffres/distribuer
Authorization: Bearer <jeton-consommateur>
Content-Type: application/json
```
Corps (forme à plat, Phase 1b) :
```json
{
"reference": "coffre:<slug>/<nom>",
"contexte_type": "mission",
"contexte_id": "mission-123"
}
```
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
```json
{
"reference": "coffre:<slug>/<nom>",
"contexte": { "type": "mission", "id": "mission-123" }
}
```
La forme à plat reste supportée ; si les deux formes sont présentes,
la forme à plat est prioritaire. Le contexte est **obligatoire** :
sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`),
la requête est refusée avant toute authentification utile.
Réponse en succès (200) :
```json
{ "reference": "coffre:<slug>/<nom>", "valeur": "<valeur>", "version": 1 }
```
La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit
jamais être écrite dans un fichier, un journal, un prompt, une étape
d'activité ou un livrable. Le patron imposé à chaque app est
**distribuer → utiliser → oublier** ; à la fin de l'opération
(fin de mission, fin de synchronisation), l'environnement qui la
contenait est détruit et rien ne persiste.
Prérequis côté ObsiGate : coffres activés
(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK
configurée (sinon 503 `kek_absente`).
## 2. Authentification : consommateur nommé, jeton propre
- Un consommateur est créé par un admin :
`POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une
seule opération avec ses octrois :
`POST /api/coffres/consommateurs/avec-octrois`
(`{"nom": "tiktak", "octrois": [{"coffre_slug": "...",
"secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre).
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création.
Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté)
et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que
`id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`).
- L'`id` du consommateur sert de `jti` révocable :
`DELETE /api/coffres/consommateurs/{id}` révoque immédiatement
toutes ses distributions, sans toucher aux autres consommateurs.
- Ce jeton est **distinct** du jeton MCP de notes et de la session
SSO : une session utilisateur, même admin, ne distribue jamais
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
distribution par session (voir §6).
- Ajouter une app = enregistrer un consommateur + créer des octrois,
**sans modification du coffre** et sans toucher à ses secrets.
## 3. Cas Spark (issue #21)
- Consommateur nommé `spark` (convention, non imposée par le code :
l'autorisation porte sur le jeton, pas sur le nom).
- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant
de la mission. La forme imbriquée
`contexte: {type: "mission", id: <id-mission>}` est acceptée.
- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la
correspondance des erreurs) : le LLM ne voit que la référence
`coffre:<slug>/<nom>` et un accès oui/non, jamais la valeur. Sans
octroi, la mission passe en `acces_requis` en nommant l'accès
manquant. Avec octroi et mission approuvée (enveloppe budgétaire),
le service Spark appelle `/distribuer` et injecte la valeur en
variable d'environnement de l'exécuteur, jamais dans le prompt.
Fin de mission = environnement détruit.
- Révocation dans ObsiGate (consommateur ou octroi) : la mission
suivante repasse en `acces_requis`. Limite assumée : une mission
déjà démarrée conserve son environnement jusqu'à sa fin.
## 4. Erreurs (codes `detail`, stables depuis la Phase 1b)
| HTTP | `detail` | Sens | Correspondance Spark |
| --- | --- | --- | --- |
| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. |
| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. |
| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. |
| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). |
| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). |
| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). |
| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). |
| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. |
| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. |
Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du
coffre ; un octroi par secret ne couvre que ce secret. Un octroi
révoqué ne doit jamais être masqué par l'absence d'un autre octroi
(priorité : ok > révoqué > expiré > absent).
## 5. Audit
Chaque distribution est auditée (`distribution_ok` ou
`distribution_refusee`, avec référence, consommateur, contexte et
code de résultat). **Aucune entrée d'audit ne contient jamais une
valeur, un texte chiffré, un nonce ou un jeton.** La route journal
(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur
ces champs même s'ils apparaissaient un jour dans une entrée.
La copie manuelle à usage unique (admin, via consommateur temporaire
créé puis révoqué immédiatement, valeur copiée au presse-papiers
sans affichage) est auditée comme une distribution de contexte
`copie_manuelle`, avec la trace distincte de ce contexte.
## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate)
- **Isolation workspace Flowdeck** : le type de cible `workspace`
existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui
que les octrois de type `consommateur`. L'isolation « un workspace
ne peut pas utiliser l'octroi d'un autre » repose donc, pour
l'instant, sur le serveur Flowdeck lui-même (il ne demande que les
références de son workspace, avec son propre jeton consommateur).
Un contrôle serveur strict (contexte workspace vérifié côté
ObsiGate contre un octroi `workspace`) reste à spécifier et à
implémenter si Bruno l'exige — il changerait la sémantique
d'autorisation de `/distribuer` et doit être coordonné avec les
apps clientes avant tout changement.
- **Distribution par session** : non implémentée, volontairement.
Elle contredirait la règle Phase 1b (distribution réservée aux
consommateurs) et exigerait une ré-authentification qui n'existe
pas encore dans ObsiGate. La copie manuelle reste le patron
supporté (consommateur temporaire, admin seulement).
- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) :
à implémenter dans chaque dépôt, en branche + PR, jamais mergés
sans go. Patron commun : le serveur distribue, utilise, oublie ;
aucune valeur en cache ; si ObsiGate est indisponible, l'opération
échoue proprement, sans repli sur une ancienne valeur.
- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) :
toujours branchés sur `api_keys.json` en clair (voir le document
de sauvegarde/restauration, §5) ; leur branchement sur le coffre
est un travail séparé de la présente Phase 2.
+390
View File
@@ -0,0 +1,390 @@
# Coffre Secrets ObsiGate — Déploiement et mise en service (Phase 5, issue #23)
Document de déploiement du coffre Secrets, issu des Phases 1a/1b
(issues #17, #18), 2 (issue #20), 3 (issue #21, côté agent-commun)
et 4 (issue #22, côté TikTak, Flowdeck, Kanbante).
**Règles de gouvernance (non négociables) :** travail en branche +
pull request, jamais sur `main`, jamais de merge, de release, de tag,
de fermeture d'issue ni de changement de label sans le go explicite
de Bruno. Ce document ne décrit aucune valeur réelle : les exemples
utilisent des valeurs fictives (`TEST-VALUE-XXXX`) et aucune KEK
n'est jamais écrite ici, ni dans un journal, ni dans une note, ni
dans le dépôt.
Documents compagnons (même branche de référence, Phase 2) :
- `docs/COFFRE-contrat-distribution.md` — contrat
`POST /api/coffres/distribuer` pour Spark, TikTak, Flowdeck,
Kanbante (authentification par consommateur, contextes, codes
d'erreur stables).
- `docs/COFFRE-sauvegarde-restauration.md` — sauvegarde
(`data/coffres.json` + `data/coffres_valeurs.json`, 0600) et
restauration sur instance vierge, perte de KEK.
## 1. Prérequis et variables d'environnement
### 1.1 Dépendance
- `cryptography>=44.0` dans `backend/requirements.txt` (dépendance
Phase 1a). Sans elle, les opérations de chiffrement répondent
503 `crypto_indisponible` : c'est un mode dégradé explicite, pas
un repli en clair. Il n'y a jamais de stockage ni de distribution
de valeur en clair.
- Chiffrement par enveloppe AES-256-GCM : KEK (256 bits) → DEK par
coffre → valeur du secret. Un tag GCM invalide refuse le
déchiffrement (testé : `tests/test_coffre_secrets.py`,
`test_tag_invalide_refuse`).
### 1.2 Variables
| Variable | Rôle | Défaut |
| --- | --- | --- |
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Tant qu'elle n'est pas à `true`, **toutes** les routes coffre répondent 503 `secrets_desactives`. | désactivé |
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). **Prioritaire** sur le fichier si les deux sont définis. | absente |
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier contenant la KEK, **en 0600, hors dépôt et hors arborescence des vaults**. Un fichier trop permissif (droits groupe/autre) est refusé explicitement (`KEKInvalideError`). | absent |
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut) : `coffres.json`, `coffres_valeurs.json` et l'historique `api_keys.json`. | `data` |
La KEK est générée une seule fois (admin, coffres activés) via
`POST /api/coffres/admin/generer-kek` ; ObsiGate **ne la stocke
jamais**. Au moment de sa génération : la copier immédiatement
dans un gestionnaire de mots de passe ou sur un support hors ligne,
hors du serveur ; vérifier la copie en la relisant depuis le
gestionnaire, jamais depuis l'écran qui ne l'affichera plus.
Ne jamais la coller dans le dépôt, dans une note ObsiGate, dans le
même dossier que `data/`, ni dans un journal.
**Recommandation de déploiement : préférer `OBSIGATE_KEK_FILE`**
(fichier 0600) à `OBSIGATE_KEK` en variable d'environnement, pour
éviter que la KEK apparaisse dans un historique de processus ou un
dump d'environnement. Si les deux sont définis, `OBSIGATE_KEK`
l'emporte — vérifier qu'aucune ancienne valeur ne traîne dans
l'environnement au moment de basculer sur le fichier.
### 1.3 Ce que la sauvegarde contient (rappel)
Deux fichiers seulement portent le coffre, tous deux en 0600 :
- `data/coffres.json` : métadonnées des coffres, DEK chiffrées,
consommateurs (hashes SHA-256 des jetons seulement, jamais le
jeton en clair), octrois, métadonnées des secrets ;
- `data/coffres_valeurs.json` : versions chiffrées des secrets.
Copiés seuls, ils ne donnent accès à aucune valeur. La KEK ne fait
pas partie de la sauvegarde : elle est copiée à part, hors serveur.
Sauvegarder les deux fichiers et la KEK au même endroit annulerait
la protection du chiffrement.
## 2. Décisions ouvertes de la spec — tranchées par défaut
Les décisions ouvertes de la spec (§15, §16), rappelées dans
l'issue #23, sont confirmées comme tranchées selon les défauts
recommandés, en attendant une décision contraire explicite de
Bruno. Aucune d'elles ne nécessite de changement de code pour la
mise en service décrite ci-dessous :
| Décision | Choix confirmé par défaut |
| --- | --- |
| Glyphe du nœud Secrets | Cadenas U+1F510 (🔐), distinct de la maison du Home et du diamant du Partage. Le nœud est un nœud virtuel sous le Home de l'utilisateur courant seulement (voir §4). |
| Stockage de la KEK | Par fichier 0600, hors dépôt (`OBSIGATE_KEK_FILE`) ; `OBSIGATE_KEK` reste supportée comme solution de secours. |
| Expiration des octrois | 90 jours par défaut à la création, renouvelable (création d'un nouvel octroi). Un octroi expiré refuse la distribution (`octroi_expire`, 403) ; un octroi révoqué refuse dès l'appel suivant (`octroi_revoque`, 403). |
| Copie manuelle | Presse-papiers seul, sans affichage de la valeur. Patron supporté : consommateur temporaire créé par un admin puis révoqué immédiatement, auditée comme distribution de contexte `copie_manuelle`. **Aucun endpoint dédié de copie par session n'est ajouté** : il contredirait la règle « distribution réservée aux consommateurs » et exigerait une ré-authentification qui n'existe pas encore — décision volontaire, à trancher avec Bruno avant toute implémentation. |
| Coffres partagés | Phase 2 ultérieure. En mise en service : coffres personnels d'abord, plus le coffre d'instance « ObsiGate interne » (slug `obsigate-interne`, admin seulement) issu de l'import. |
## 3. Mise en service pas à pas
**Ordre impératif.** Chaque étape est validée avant de passer à la
suivante. En particulier : **les lecteurs internes sont branchés
sur le coffre AVANT toute purge du clair** (étape 4 avant étape 7),
et la purge n'a lieu qu'après validation complète (étape 6).
### Étape 0 — Préparer, coffre encore désactivé
1. Déployer le code coffre (Phases 1–2) sur l'instance, **sans
activer le flag** : `OBSIGATE_SECRETS_ENABLED` reste absent ou à
`false`. Vérifier que toutes les routes `/api/coffres/*`
répondent 503.
2. Sauvegarder l'état actuel : copie de `data/` (dont
`api_keys.json`) et de la configuration, hors serveur.
3. Générer la KEK (voir §1.2) et la copier hors serveur. Ne pas
encore la référencer dans la configuration de l'instance si
l'étape 1 n'est pas terminée — une KEK présente sans flag actif
ne distribue rien, mais sa présence doit rester traçable.
### Étape 1 — Créer les consommateurs et les octrois
Avant d'activer le flag, préparer les consommateurs nommés, un par
application cliente (convention de nommage, non imposée par le
code — l'autorisation porte sur le jeton, pas sur le nom) :
| Consommateur | Usage | Contexte de distribution |
| --- | --- | --- |
| `spark` | Agent Spark (agent-commun, Phase 3) | `contexte_type = "mission"`, `contexte_id` = id de mission (forme imbriquée `contexte: {type, id}` acceptée) |
| `tiktak` | Synchronisation TikTak (Phase 4) | `contexte_type = "tiktak_sync"`, `contexte_id` = id utilisateur |
| `flowdeck` | Connecteurs Flowdeck (Phase 4) | Contexte workspace, vérifié côté Flowdeck (voir §5) |
| `kanbante` | Webhooks Kanbante (Phase 4) | Contexte webhook, distribution à chaque appel puis oubli |
Création en une seule opération avec les octrois :
```
POST /api/coffres/consommateurs/avec-octrois
{"nom": "tiktak", "octrois": [{"coffre_slug": "...", "secret_nom": "..."}]}
```
- `secret_nom` absent = tout le coffre (octroi coffre-wide).
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la
création. Le noter immédiatement dans le coffre de
configuration de l'application cliente concernée, jamais dans
une note ObsiGate ni dans le dépôt. Il n'est jamais relisté :
`GET /api/coffres/consommateurs` ne renvoie que `id`, `nom`,
`jeton_prefixe`, `statut`, `cree_par`, `cree_le`.
- La révocation est immédiate : `DELETE
/api/coffres/consommateurs/{id}` révoque toutes les
distributions de ce consommateur, sans toucher aux autres.
Une mission Spark déjà démarrée conserve son environnement
jusqu'à sa fin ; la mission suivante repasse en `acces_requis`.
À ce stade, les consommateurs et octrois peuvent être créés alors
que les secrets n'existent pas encore : les octrois pointent vers
des coffres/secrets à créer aux étapes suivantes. Vérifier la
liste des consommateurs et des octrois avant d'activer le flag.
### Étape 2 — Activer le flag
1. Définir `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt) puis
`OBSIGATE_SECRETS_ENABLED=true`.
2. Redémarrer l'instance.
3. Vérifier : la vue Secrets s'ouvre ; `GET /api/coffres` répond
200 (liste vide ou coffres existants) ; sans KEK valide, les
routes répondent 503 `kek_absente` — dans ce cas, corriger la
KEK avant toute création de secret.
### Étape 3 — Importer l'existant (admin seulement)
Bouton « Importer l'existant » de la vue Secrets (admin
seulement), en deux temps séparés :
1. **Aperçu** : `GET /api/coffres/migration/api-keys` liste les
candidats de `data/api_keys.json` (clés IA et clés d'outils).
2. **Import** : `POST /api/coffres/migration/api-keys/import`
copie les entrées avec valeur vers le coffre d'instance
« ObsiGate interne » (slug `obsigate-interne`), chiffrées.
Un secret déjà présent n'est **jamais écrasé**.
`api_keys.json` n'est **pas modifié** par l'import.
Vérifier après import : les secrets importés apparaissent dans la
vue Secrets avec `has_value = true`, et aucune valeur n'est
visible (métadonnées seules : nom, type, version, dates, octrois
actifs — jamais de valeur, jamais de texte chiffré, jamais de
nonce).
### Étape 4 — Brancher les lecteurs internes AVANT toute purge
**Point de vigilance principal de la mise en service.** Les
lecteurs actuels d'ObsiGate (`backend/ai.py`,
`backend/tools/secrets.py`) lisent encore `data/api_keys.json`
directement ; ils ne sont pas encore branchés sur le coffre
(reste à faire explicite des Phases 2–5). Tant que ce branchement
n'est pas livré et validé, **purger le clair priverait l'IA et les
outils de leurs clés**.
Ordre à respecter :
1. Ne pas purger.
2. Livrer (en branche + PR, jamais mergé sans go) le branchement
des lecteurs internes sur une distribution depuis le coffre
d'instance, puis le valider sur l'instance : l'IA et les outils
fonctionnent avec `api_keys.json` rendu temporairement
inaccessible en lecture (test de coupure contrôlée), ou à
défaut avec un secret de test distribué depuis le coffre.
3. Seulement après cette validation, passer à l'étape 7.
Si Bruno accepte une interruption temporaire de l'IA/outils, le
documenter explicitement avant la purge ; sinon, la purge attend
le branchement.
### Étape 5 — Brancher les applications clientes
Pour chaque application (branches dédiées, jamais mergées sans
go), appliquer le patron commun **distribuer → utiliser →
oublier** : le serveur distribue à chaque opération, utilise la
valeur en mémoire, puis l'oublie ; aucune valeur en cache, aucune
persistance. Si ObsiGate est indisponible, l'opération échoue
proprement, sans repli sur une ancienne valeur.
- **Spark (agent-commun, Phase 3)** : identités référencées
`coffre:<slug>/<nom>` (slug 3–64, nom 3–128, minuscules /
chiffres / `-` / `_`), sans colonne valeur ; le LLM ne voit que
la référence et un accès oui/non. Sans octroi, la mission passe
en `acces_requis` en nommant l'accès manquant ; avec octroi et
mission approuvée (enveloppe budgétaire), distribution puis
injection en variable d'environnement de l'exécuteur, jamais
dans le prompt, jamais dans les étapes d'activité. Fin de
mission = environnement détruit.
- **TikTak (Phase 4)** : référence seule stockée ; distribution
serveur à chaque synchro ; masquage côté Android (ni référence
complète ni valeur dans les réponses vues par une clé `tck_`).
Les configurations existantes sont marquées `a_resaisir = 1`
(migration 019 : colonne en clair supprimée, référence vide) :
il n'y a **pas de conversion automatique** — chaque
configuration doit être re-saisie par référence.
- **Flowdeck (Phase 4)** : `workspace_id` + référence sur les
connecteurs ; distribution en mémoire ; isolation A vs B
testée (un workspace ne distribue pas avec l'octroi d'un autre).
Les connecteurs natifs Gitea/GitHub et les clés LLM ne sont pas
basculés ; pas de sélecteur UI (API seulement).
- **Kanbante (Phase 4)** : secret webhook migré en référence,
distribuée à chaque webhook puis oubliée ; client générique
réutilisable. Les jetons forge chiffrés Fernet ne sont **pas**
migrés (distribution par appel requise, incompatible avec le
cache `ForgeRegistry`) ; les secrets webhook historiques en
clair restent acceptés par compatibilité, sans migration
automatique.
### Étape 6 — Valider (avant purge)
Exécuter la checklist de validation de l'instance réelle (§4).
Tant qu'un point échoue, ne pas purger et ne pas considérer la
mise en service comme terminée.
### Étape 7 — Purger, seulement après validation
`POST /api/coffres/migration/api-keys/purger`, **après
confirmation explicite** (`confirmer = true`), et uniquement pour
les noms migrés. Sans confirmation, rien n'est supprimé — jamais
automatiquement.
Après purge, vérifier :
- `api_keys.json` ne contient plus les noms purgés ;
- l'IA et les outils fonctionnent toujours (lecteurs branchés,
étape 4) ;
- une distribution de test via un consommateur existant aboutit
(succès seul vérifié, valeur ni affichée ni consignée).
### Étape 8 — Sauvegarder et tester la restauration
1. Sauvegarder `data/coffres.json` + `data/coffres_valeurs.json`
(0600) par la sauvegarde habituelle du serveur, et la KEK à
part, hors serveur (§1.3).
2. Rejouer la restauration sur une instance vierge (procédure
`docs/COFFRE-sauvegarde-restauration.md` §3 : arrêt de la
cible, copie des deux fichiers en 0600, même KEK, redémarrage,
vérification `has_value = true` + distribution de test +
`distribution_ok` dans le journal). La suite automatisée
(`tests/test_coffre_secrets.py`, classe
`TestRestaurationVierge`) simule cette restauration ; elle ne
remplace pas un essai sur instance réelle.
3. En cas de perte de KEK : perte d'accès assumée, par
construction — aucun moyen de récupérer les valeurs. Recréer
les coffres avec une nouvelle KEK, régénérer les valeurs côté
fournisseur, révoquer les anciennes côté fournisseur, recréer
consommateurs et octrois. Ne jamais tenter de « réparer » les
anciens fichiers avec une nouvelle KEK (le déchiffrement
échouera sur tag GCM invalide, ce qui est attendu).
## 4. Checklist de validation par Bruno sur instance réelle
Validation exigée par l'issue #23 avant tout usage réel. Chaque
point est validé sur capture, comme pour les autres livraisons :
- [ ] **Sidebar** : le nœud « Secrets » (cadenas U+1F510)
apparaît sous le Home de l'utilisateur courant seulement, et
pas sous les autres vaults ; cliquer dessus ouvre la vue
Secrets sans naviguer dans les fichiers.
- [ ] **Secret de test** : création d'un secret de test
(valeur fictive, ex. `TEST-VALUE-XXXX`) dans un coffre
personnel, puis lecture : métadonnées seules, aucune valeur
affichée, aucun texte chiffré, aucun nonce.
- [ ] **Journal d'audit** : après une distribution de test, le
journal contient `distribution_ok` pour la référence, et un
`grep` du journal sur la valeur de test ne renvoie **rien**
(ni valeur, ni texte chiffré, ni nonce, ni jeton).
- [ ] **Autorisations** : un admin ne voit pas le coffre
personnel d'autrui ; une ACL `*` sur un vault ne donne aucun
droit sur un coffre ; une session utilisateur, même admin, ne
distribue pas (401 `consommateur_invalide`).
- [ ] **Distribution** : contexte manquant refusé (400
`contexte_manquant`) ; octroi révoqué refusé dès l'appel
suivant (403 `octroi_revoque`) ; octroi expiré refusé (403
`octroi_expire`) ; secret expiré refusé (410 `secret_expire`).
- [ ] **Import / purge** : l'import ne modifie pas
`api_keys.json` et n'écrase pas un secret existant ; la purge
sans confirmation explicite ne supprime rien.
- [ ] **Restauration** : sauvegarde + KEK externe restaurées sur
une instance vierge, distributions fonctionnelles.
- [ ] **Intégrations** : mission Spark en `acces_requis` sans
octroi, puis injection exécuteur sans trace dans les étapes ;
synchro TikTak sans valeur côté Android ; isolation workspace
Flowdeck (A ne distribue pas avec l'octroi de B).
- [ ] **Modale** : la modale de création de secret n'est plus
coupée en haut (`.obsigate-modal` bornée au viewport, corps
seul défilant, en-tête et pied toujours visibles).
## 5. Limites connues à la mise en service
À lire avant de déclarer la mise en service terminée :
- **Lecteurs internes non branchés** (`ai.py`,
`tools/secrets.py` lisent encore `api_keys.json` en clair) :
purger avant de les brancher priverait l'IA/outils de leurs
clés (§3, étape 4).
- **Endpoint dédié de copie manuelle par session non ajouté**
(décision volontaire, §2) : la copie manuelle passe par un
consommateur temporaire admin, presse-papiers seul.
- **Isolation workspace Flowdeck non contrôlée côté ObsiGate** :
`/distribuer` n'autorise aujourd'hui que les octrois de type
`consommateur` ; l'isolation workspace repose sur le serveur
Flowdeck lui-même (il ne demande que les références de son
workspace, avec son propre jeton). Un contrôle serveur strict
reste à spécifier si Bruno l'exige.
- **Identités agent-commun** : détenteur libre hors coffre
conservé (historique) ; révocation Identités non propagée vers
ObsiGate (pas d'endpoint) ; exécuteurs CLI synchrones
uniquement.
- **TikTak** : « sélecteur » = saisie validée, pas de liste
déroulante (aucune API de listage côté ObsiGate) ;
configurations existantes à re-saisir, pas de conversion
automatique.
- **Flowdeck** : natifs Gitea/GitHub et clés LLM non basculés ;
pas de sélecteur UI (API seulement) ; `workspace_id` pas encore
branché depuis AgentEngine.
- **Kanbante** : jetons forge chiffrés Fernet non migrés ;
pas de migration automatique des secrets webhook historiques.
- **Recherche / graphe / index** : par construction, 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. Aucune métadonnée de secret ne doit apparaître dans
ces index (vérification par revue de code, pas de suite
automatisée dédiée — voir le bilan Phase 5).
## 6. Repli (rollback)
- Désactiver le flag (`OBSIGATE_SECRETS_ENABLED` absent ou
`false`) : toutes les routes coffre répondent 503, les fichiers
`coffres.json` / `coffres_valeurs.json` restent intacts et
re-distribueront dès réactivation avec la même KEK.
- Révoquer un consommateur (`DELETE
/api/coffres/consommateurs/{id}`) coupe immédiatement ses
distributions, sans toucher aux autres consommateurs ni aux
secrets.
- Ne jamais « réparer » des fichiers chiffrés avec une nouvelle
KEK, ne jamais restaurer `coffres.json` sans
`coffres_valeurs.json` (et inversement), ne jamais copier la
KEK au même endroit que la sauvegarde.
## 7. Références
- Spec « Coffre de secrets ObsiGate » §6 (autorisations), §7/§10/§11
(API), §13/§14 (sauvegarde/restauration), §15/§16 (tests,
déploiement).
- Issues ObsiGate : #17 (Phase 1a), #18 (Phase 1b), #19 (Phase 1c
UI), #20 (Phase 2), #21 (Phase 3, Spark), #22 (Phase 4, TikTak /
Flowdeck / Kanbante), #23 (Phase 5, ce document).
- Suites de tests : `tests/test_coffre_secrets.py` (ObsiGate, 35
tests), `tests/test_identites_coffre_phase3.py` (agent-commun,
16 tests), `tests/test_coffre_reference.py` (TikTak, 17 fonctions
/ 26 cas), `tests/test_coffre_reference_flowdeck.py` (Flowdeck,
16 tests), `tests/test_coffre_client.py` (Kanbante, 11 fonctions
/ 25 cas). Voir le bilan Phase 5 pour la matrice de couverture
complète et les manques.
+155
View File
@@ -0,0 +1,155 @@
# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20)
Ce document décrit la seule procédure supportée pour sauvegarder et
restaurer le coffre Secrets, ainsi que le cas de perte de la KEK.
Il complète la spécification du coffre (§13, §14) ; il ne décrit
aucune valeur réelle — les exemples utilisent des valeurs fictives.
## 1. Ce qui constitue une sauvegarde
Deux fichiers, et seulement ces deux fichiers, portent le coffre :
| Fichier | Contenu |
| --- | --- |
| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. |
| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). |
Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** :
copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est
chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son
coffre. Ils peuvent donc transiter par la sauvegarde habituelle du
serveur, avec les mêmes permissions restrictives que sur l'instance
(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans
une note ObsiGate, ni dans un journal.
La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à
part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK
au même endroit annulerait la protection du chiffrement.
## 2. Sauvegarder la KEK (copie externe, hors serveur)
La KEK est une clé maître 256 bits (base64 de 32 octets), générée une
seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres
activés) et **jamais stockée par ObsiGate**. Au moment de sa
génération :
1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou
sur un support hors ligne, hors du serveur ObsiGate.
2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate,
jamais dans le même dossier que `data/`, jamais dans un journal.
3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis
l'écran qui ne l'affichera plus jamais).
Variables d'environnement (documentées aussi dans `.env.example`,
sans aucune valeur) :
| Variable | Rôle | Défaut |
| --- | --- | --- |
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé |
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente |
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent |
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` |
**Aucune autre variable ne doit contenir une valeur de secret du
coffre.** En particulier, ne créez jamais de variable du type
`COFFRE_<nom>` avec une valeur : la seule valeur externe autorisée est
la KEK, et elle n'est pas une valeur de secret du coffre.
## 3. Restauration sur une instance (y compris vierge)
Prérequis : une instance ObsiGate avec le coffre activé
(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance
d'origine.
1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune
écriture n'a lieu pendant la copie).
2. Restaurez les deux fichiers `coffres.json` et
`coffres_valeurs.json` dans le répertoire de données cible, en
permissions 0600.
3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via
`OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt).
4. Redémarrez l'instance.
5. Vérifiez, sans jamais afficher ni journaliser de valeur :
- la vue Secrets liste les coffres et les secrets avec
`has_value = true` ;
- une distribution de test via un consommateur existant (ou un
consommateur de test, révoqué ensuite) aboutit — côté serveur,
le succès seul est vérifié, la valeur n'est ni affichée ni
consignée ;
- le journal d'audit contient `distribution_ok` pour cette
référence.
**Test de restauration obligatoire avant la mise en service réelle
(issue #20)** : la procédure ci-dessus a été automatisée dans la
suite de tests (`tests/test_coffre_secrets.py`, classe
`TestRestaurationVierge`) — copie des deux fichiers vers un
répertoire vierge, même KEK, distributions fonctionnelles — et doit
aussi être rejouée une fois sur une instance réelle par l'opérateur
avant de considérer la sauvegarde comme valide.
## 4. Perte de la KEK : perte d'accès assumée, par construction
Si la KEK est perdue, **il n'existe aucun moyen de récupérer les
valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les
valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM,
authentifié). Il n'y a ni clé de secours, ni dérivation depuis le
secret d'application, ni porte dérobée.
Procédure en cas de perte :
1. Considérez toutes les valeurs du coffre comme perdues.
2. Créez de nouveaux coffres avec une nouvelle KEK.
3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur**
(nouvelle clé API, nouveau jeton) et créez un nouveau secret avec
cette nouvelle valeur ; révoquez les anciennes valeurs côté
fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles
ne sont pas révoquées là-bas.
4. Recréez les consommateurs et les octrois (les anciens octrois
pointent vers des secrets qui n'existent plus dans les nouveaux
coffres).
5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle
KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le
comportement attendu, pas un bug.
La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas
une récupération : elle exige l'ancienne KEK encore valide pour
re-chiffrer les DEK, puis le remplacement de la KEK dans
l'environnement ou le fichier KEK.
## 5. Migration `api_keys.json` (Phase 2) — avertissement important
Le bouton « Importer l'existant » de la vue Secrets (admin seulement)
copie les clés IA et clés d'outils de `data/api_keys.json` vers le
coffre d'instance « ObsiGate interne » (slug `obsigate-interne`),
en deux temps :
1. **Import** : les entrées avec valeur sont chiffrées dans le coffre
(un secret déjà présent n'est jamais écrasé) ; `api_keys.json`
n'est pas modifié.
2. **Purge** : après confirmation explicite, et uniquement pour les
noms migrés, les entrées sont supprimées du clair. Sans
confirmation, rien n'est supprimé — jamais automatiquement.
**Avertissement** : les lecteurs actuels d'ObsiGate
(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore
`api_keys.json` directement ; ils ne sont pas encore branchés sur le
coffre. Purger le clair prive donc ces lecteurs de la clé tant que
leur lecteur n'est pas branché sur une distribution depuis le coffre.
Ne purgez que si vous avez vérifié que le consommateur concerné
distribue depuis le coffre, ou si vous acceptez cette interruption.
Ce branchement des lecteurs internes est un reste à faire explicite
(voir §6).
## 6. Restes à faire (après cette Phase 2)
- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur
une distribution depuis le coffre d'instance, pour que la purge du
clair ne coupe pas l'IA ni les outils.
- Rejouer le test de restauration sur une instance réelle (la suite
automatisée simule une instance vierge, elle ne remplace pas un
essai sur l'instance de production).
- Endpoint de distribution par session utilisateur (copie manuelle
sans consommateur temporaire) : non ajouté ici, car il contredirait
la règle Phase 1b (distribution réservée aux consommateurs) et
exigerait une ré-authentification qui n'existe pas encore — décision
à prendre avec l'opérateur avant toute implémentation.
+20 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.78.2 | **Dernière mise à jour :** 2026-10-26 > **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -30,6 +30,24 @@
## 🔵 En cours ## 🔵 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) ### 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) - **Effort :** ~0,1 jour | **Impact :** 🔴 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
@@ -1150,6 +1168,7 @@
| 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) | | 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-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) | | 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) |
--- ---
@@ -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.
+4 -4
View File
@@ -19,8 +19,8 @@
var mode = localStorage.getItem("obsigate-theme-mode") || "dark"; var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
root.setAttribute("data-theme", mode); root.setAttribute("data-theme", mode);
var light = mode === "light" || mode === "sepia"; var light = mode === "light" || mode === "sepia";
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117"); root.style.setProperty("--bg-primary", light ? "#fbfbfa" : "#17181a");
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3"); root.style.setProperty("--text-primary", light ? "#242624" : "#eceeeb");
} catch (e) { root.setAttribute("data-theme", "dark"); } } catch (e) { root.setAttribute("data-theme", "dark"); }
})(); })();
</script> </script>
@@ -41,8 +41,8 @@
min-height: 100vh; min-height: 100vh;
overflow: auto; /* override SPA style.css: body{overflow:hidden} */ overflow: auto; /* override SPA style.css: body{overflow:hidden} */
height: auto; /* override SPA style.css: body{height:100vh} */ height: auto; /* override SPA style.css: body{height:100vh} */
background: var(--bg-primary, #0f1117); background: var(--bg-primary, #17181a);
color: var(--text-primary, #e6edf3); color: var(--text-primary, #eceeeb);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
} }
+33
View File
@@ -42,6 +42,7 @@
var etatConnu = false; var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null; var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() { function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -269,6 +270,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' + ' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' + ' <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-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>'; '</div>';
document.body.appendChild(root); document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs"); msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -277,6 +279,7 @@
sendBtn = root.querySelector("button[type=submit]"); sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre"); titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions"); tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer); root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir); root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion); root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -331,6 +334,35 @@
titreEl.title = titreEl.textContent; 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() { function verifierIdentite() {
if (etatConnu) return; if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" }) fetch(API + "/api/v1/me", { credentials: "include" })
@@ -807,6 +839,7 @@
root.classList.add("open"); root.classList.add("open");
appliquerTheme(); appliquerTheme();
verifierIdentite(); verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300); setTimeout(function () { inputEl.focus(); }, 300);
} }
function fermer() { if (root) root.classList.remove("open"); } function fermer() { if (root) root.classList.remove("open"); }
+4 -4
View File
@@ -10,7 +10,7 @@
name="description" name="description"
content="Porte d'entrée web pour vos vaults Obsidian - Accédez, naviguez et recherchez dans toutes vos notes" content="Porte d'entrée web pour vos vaults Obsidian - Accédez, naviguez et recherchez dans toutes vos notes"
/> />
<meta name="theme-color" content="#2563eb" /> <meta name="theme-color" content="#7c5cd6" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta <meta
@@ -54,14 +54,14 @@
#boot-splash { #boot-splash {
position: fixed; inset: 0; z-index: 2147483647; position: fixed; inset: 0; z-index: 2147483647;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
background: radial-gradient(1200px 600px at 50% -10%, #1b2030 0%, #0e1117 60%); background: radial-gradient(1200px 600px at 50% -10%, #26272b 0%, #17181a 60%);
color: #e6e8ee; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; color: #eceeeb; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
} }
body.app-booted #boot-splash { display: none; } body.app-booted #boot-splash { display: none; }
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; } .bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.bs-logo { .bs-logo {
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
font-size: 38px; background: #171c26; border: 1px solid #2a3242; border-radius: 18px; font-size: 38px; background: #1f2023; border: 1px solid #33353a; border-radius: 18px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
} }
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; } .bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
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 { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js'; import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.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 { performAdvancedSearch } from './search.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { filterChatMessages as _filterChat } from './filechat.js'; import { filterChatMessages as _filterChat } from './filechat.js';
@@ -46,6 +47,168 @@ function _sharedIcon(size = 16) {
return i; 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. */ /** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) { function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon(); 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 // Replace container content in a single batch operation to avoid flash
container.textContent = ""; container.textContent = "";
container.appendChild(fragment); 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); container.appendChild(fragment);
safeCreateIcons(); safeCreateIcons();
} }
+68 -3
View File
@@ -1,5 +1,5 @@
/* ObsiGate — Theme System /* ObsiGate — Theme System
* 15 themes x 2 modes (dark/light) * 16 themes x 2 modes (dark/light)
* Persisted in localStorage as 'obsigate-theme' * Persisted in localStorage as 'obsigate-theme'
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js' * Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
*/ */
@@ -45,6 +45,71 @@ var THEMES = {
} }
}, },
// ── Thème Spark ──────────────────────────────────────────────────────
// Palette source : console Spark (agent.dracodev.net), app/console/console.css
// (variables --p-*). Mapping Spark -> ObsiGate, identique en structure pour
// dark et light ; seules les valeurs changent (sauf accent/ok/warn/err).
// Fidèle (valeur Spark directe) :
// fond -> --bg-primary, --surface2 ; surface -> --surface ;
// chip -> --bg-secondary, --tag-bg ; chip-survol -> --bg-hover ;
// bord -> --border ; chip-bord -> --border-md, --scrollbar, --resize-handle ;
// texte -> --text-primary, --text ; doux -> --text-secondary, --text-2 ;
// faint -> --text-muted, --text-3 ; champ -> --search-bg ;
// code -> --code-bg ; accent -> --accent, --accent-card, --purple, --ai ;
// lien -> --accent-text (variante lisible de l'accent sur fond teinté) ;
// ok -> --accent-green, --success, --green ;
// danger -> --danger ; danger-fond -> --danger-bg ;
// pastilles ok (fond) -> --success-bg, --green-bg ;
// pastilles ok (texte) -> --ok ; ambre -> --warn ; rouge -> --err.
// Approximations documentées (ObsiGate a plus de variables que Spark,
// ce ne sont PAS des valeurs Spark) :
// --bg-sidebar <- fond (Spark n'a pas de token sidebar dédié) ;
// --accent-bg <- agent (surface teintée de Spark, pas de teinte lavande) ;
// --surface3 <- agent ; --purple-bg <- appro-fond (fond teinté le plus
// proche, la palette Spark n'a pas de teinte lavande) ;
// --overlay-bg <- convention ObsiGate (rgba noir 0.3 clair / 0.5 sombre,
// aucun token voile dans la palette Spark).
// Non reportés (aucune variable ObsiGate correspondante dans les 39) :
// bulle utilisateur/envoi #2f6fed, titres approbation #9a6a1c / #e8bc7a.
// Une seule entrée 'spark' : dark et light sont ses deux modes, jamais
// deux thèmes séparés. Modes high-contrast/sepia générés comme pour les
// autres thèmes (à partir de dark/light).
'spark': {
name: 'Spark', desc: 'Palette de la console Spark',
modes: {
dark: {
'--bg-primary': '#17181a', '--bg-secondary': '#26272b', '--bg-sidebar': '#17181a',
'--bg-hover': '#2e3036', '--border': '#33353a', '--text-primary': '#eceeeb',
'--text-secondary': '#a6aba0', '--text-muted': '#83887e', '--accent': '#7c5cd6',
'--accent-green': '#2ea36b', '--tag-bg': '#26272b', '--tag-text': '#7c5cd6',
'--code-bg': '#2c2e33', '--search-bg': '#1f2023', '--scrollbar': '#3a3c41',
'--resize-handle': '#3a3c41', '--overlay-bg': 'rgba(0,0,0,0.5)', '--danger': '#e8a08d',
'--danger-bg': '#40241f', '--success': '#2ea36b', '--success-bg': '#173d29',
'--accent-card': '#7c5cd6', '--accent-bg': '#26272b', '--accent-text': '#b39df5',
'--green': '#2ea36b', '--green-bg': '#173d29', '--purple': '#7c5cd6',
'--purple-bg': '#2a2118', '--surface': '#1f2023', '--surface2': '#17181a',
'--surface3': '#26272b', '--border-md': '#3a3c41', '--text': '#eceeeb',
'--text-2': '#a6aba0', '--text-3': '#83887e', '--ok': '#7fd6a8',
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
},
light: {
'--bg-primary': '#fbfbfa', '--bg-secondary': '#f4f5f1', '--bg-sidebar': '#fbfbfa',
'--bg-hover': '#e9ebe4', '--border': '#e4e5e0', '--text-primary': '#242624',
'--text-secondary': '#6c7268', '--text-muted': '#8a8f84', '--accent': '#7c5cd6',
'--accent-green': '#2ea36b', '--tag-bg': '#f4f5f1', '--tag-text': '#7c5cd6',
'--code-bg': '#e8eae3', '--search-bg': '#ffffff', '--scrollbar': '#d6d8d1',
'--resize-handle': '#d6d8d1', '--overlay-bg': 'rgba(0,0,0,0.3)', '--danger': '#a03a28',
'--danger-bg': '#f6e3de', '--success': '#2ea36b', '--success-bg': '#dff2e7',
'--accent-card': '#7c5cd6', '--accent-bg': '#eef0ea', '--accent-text': '#5b3df0',
'--green': '#2ea36b', '--green-bg': '#dff2e7', '--purple': '#7c5cd6',
'--purple-bg': '#fdf6ec', '--surface': '#ffffff', '--surface2': '#fbfbfa',
'--surface3': '#eef0ea', '--border-md': '#d6d8d1', '--text': '#242624',
'--text-2': '#6c7268', '--text-3': '#8a8f84', '--ok': '#1d7a4c',
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
}
}
},
'github': { 'github': {
name: 'GitHub', desc: 'Contraste doux', name: 'GitHub', desc: 'Contraste doux',
modes: { modes: {
@@ -523,7 +588,7 @@ var THEMES = {
}; };
// ── Theme Engine ───────────────────────────────────────────────────── // ── Theme Engine ─────────────────────────────────────────────────────
var _currentTheme = 'defaut-obsigate'; var _currentTheme = 'spark';
var _currentMode = 'dark'; var _currentMode = 'dark';
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']; var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
@@ -588,7 +653,7 @@ function applyTheme(themeKey, mode) {
root.style.setProperty(key, vars[key]); root.style.setProperty(key, vars[key]);
}); });
// #180 — the spreadsheet grid follows the active theme: derive its tokens // #180 — the spreadsheet grid follows the active theme: derive its tokens
// from the generic surfaces so every theme (15 built-in × 4 modes, custom // from the generic surfaces so every theme (16 built-in × 4 modes, custom
// imports) gets a matching grid instead of the hardcoded dark/light pair. // imports) gets a matching grid instead of the hardcoded dark/light pair.
// color-mix keeps the zebra stripe subtle on both dark and light grounds. // color-mix keeps the zebra stripe subtle on both dark and light grounds.
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the // #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
+103 -1
View File
@@ -2666,5 +2666,107 @@
"guide105.dl_md_title": "Download this guide as Markdown", "guide105.dl_md_title": "Download this guide as Markdown",
"guide105.dl_pdf_title": "Download this guide as PDF", "guide105.dl_pdf_title": "Download this guide as PDF",
"guide105.export_title": "ObsiGate User Guide", "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",
"coffres.import_existing": "Import existing",
"coffres.import_title": "Import existing keys",
"coffres.import_has_value": "Value present",
"coffres.import_no_value": "No value",
"coffres.import_already_exists": "Already in the vault",
"coffres.import_migrated": "Migrated",
"coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.",
"coffres.import_done": "Import finished",
"coffres.import_purge": "Delete plaintext",
"coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:",
"coffres.import_purged": "Plaintext deleted"
} }
+103 -1
View File
@@ -2666,5 +2666,107 @@
"guide105.dl_md_title": "Télécharger ce guide en Markdown", "guide105.dl_md_title": "Télécharger ce guide en Markdown",
"guide105.dl_pdf_title": "Télécharger ce guide en PDF", "guide105.dl_pdf_title": "Télécharger ce guide en PDF",
"guide105.export_title": "Guide d'utilisation ObsiGate", "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",
"coffres.import_existing": "Importer l'existant",
"coffres.import_title": "Importer les clés existantes",
"coffres.import_has_value": "Valeur présente",
"coffres.import_no_value": "Aucune valeur",
"coffres.import_already_exists": "Déjà dans le coffre",
"coffres.import_migrated": "Migré",
"coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.",
"coffres.import_done": "Import terminé",
"coffres.import_purge": "Supprimer le clair",
"coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :",
"coffres.import_purged": "Clair supprimé"
} }
+90 -44
View File
@@ -11,58 +11,58 @@
/* ── Editor save dot state colors ── */ /* ── Editor save dot state colors ── */
:root { :root {
--ok: #34d399; --ok: #7fd6a8;
--warn: #fbbf24; --warn: #e8a13c;
--ai: #8b5cf6; --ai: #7c5cd6;
--err: #f87171; --err: #cf4b32;
} }
/* ===== THEME — DARK (fallback default, no data-theme) ===== */ /* ===== THEME — DARK (fallback default, no data-theme) ===== */
:root { :root {
--bg-primary: #0f1117; --bg-primary: #17181a;
--bg-secondary: #161b22; --bg-secondary: #26272b;
--bg-sidebar: #13161d; --bg-sidebar: #17181a;
--bg-hover: #1f2430; --bg-hover: #2e3036;
--border: #21262d; --border: #33353a;
--text-primary: #e6edf3; --text-primary: #eceeeb;
--text-secondary: #8b949e; --text-secondary: #a6aba0;
--text-muted: #484f58; --text-muted: #83887e;
--accent: #58a6ff; --accent: #7c5cd6;
--accent-green: #3fb950; --accent-green: #2ea36b;
--tag-bg: #1f2a3a; --tag-bg: #26272b;
--tag-text: #79c0ff; --tag-text: #7c5cd6;
--code-bg: #161b22; --code-bg: #2c2e33;
--search-bg: #21262d; --search-bg: #1f2023;
--scrollbar: #30363d; --scrollbar: #3a3c41;
--resize-handle: #30363d; --resize-handle: #3a3c41;
--overlay-bg: rgba(0, 0, 0, 0.5); --overlay-bg: rgba(0, 0, 0, 0.5);
--danger: #ff7b72; --danger: #e8a08d;
--danger-bg: #3d1a18; --danger-bg: #40241f;
--success: #3fb950; --success: #2ea36b;
--success-bg: #1a3d1f; --success-bg: #173d29;
--accent-card: #58a6ff; --accent-card: #7c5cd6;
--accent-bg: #1f2a3a; --accent-bg: #26272b;
--accent-text: #79c0ff; --accent-text: #b39df5;
--green: #3fb950; --green: #2ea36b;
--green-bg: #1a3d1f; --green-bg: #173d29;
--purple: #a371f7; --purple: #7c5cd6;
--purple-bg: #2d1b4e; --purple-bg: #2a2118;
--surface: #161b22; --surface: #1f2023;
--surface2: #0f1117; --surface2: #17181a;
--surface3: #0d0f14; --surface3: #26272b;
--border-md: #30363d; --border-md: #3a3c41;
--text: #e6edf3; --text: #eceeeb;
--text-2: #8b949e; --text-2: #a6aba0;
--text-3: #484f58; --text-3: #83887e;
--mono: "JetBrains Mono", monospace; --mono: "JetBrains Mono", monospace;
--radius: 6px; --radius: 6px;
--radius-lg: 10px; --radius-lg: 10px;
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the /* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
chrome (toolbar/panels), headers are clearly distinct from cells. */ chrome (toolbar/panels), headers are clearly distinct from cells. */
--grid-bg: #161b22; --grid-bg: #1f2023;
--grid-header-bg: #1f2430; --grid-header-bg: #2e3036;
--grid-header-text: #e6edf3; --grid-header-text: #eceeeb;
--grid-border: #3d444d; --grid-border: #33353a;
--grid-zebra: rgba(255, 255, 255, 0.025); --grid-zebra: rgba(255, 255, 255, 0.025);
} }
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 16px;
box-sizing: border-box;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 200ms ease; transition: opacity 200ms ease;
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
min-width: 400px; /* Correctif Phase 2 (modale de création de secret coupée en haut) :
max-width: 600px; sans max-height ni scroll interne, un formulaire long dépassait le
viewport. La modale est désormais bornée au viewport, en colonne
flex, avec seul le corps qui défile ; l'en-tête et le pied restent
toujours visibles. Sur petit écran, la largeur est bornée aussi. */
min-width: min(400px, calc(100vw - 32px));
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
transform: scale(0.9); transform: scale(0.9);
transition: transform 200ms ease; transition: transform 200ms ease;
} }
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-shrink: 0;
} }
.obsigate-modal-title { .obsigate-modal-title {
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
.obsigate-modal-body { .obsigate-modal-body {
padding: 20px; padding: 20px;
overflow-y: auto;
min-height: 0;
} }
.obsigate-modal-footer { .obsigate-modal-footer {
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
flex-shrink: 0;
} }
.modal-input { .modal-input {
@@ -14331,3 +14345,35 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
color: var(--text-primary); font: inherit; font-size: 13.5px; color: var(--text-primary); font: inherit; font-size: 13.5px;
} }
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; } .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", "name": "obsigate",
"version": "2.78.2", "version": "2.81.0",
"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.", "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", "main": "patch.js",
"directories": { "directories": {
+680
View File
@@ -0,0 +1,680 @@
"""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
import shutil
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())
# ---------------------------------------------------------------------------
# Phase 2 — Migration api_keys.json (issue #20)
# ---------------------------------------------------------------------------
class TestMigrationApiKeys:
def _ecrire_api_keys(self, tmp_path):
(tmp_path / "api_keys.json").write_text(
json.dumps(
{
"DEEPSEEK_API_KEY": VALEUR_TEST,
"OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2,
"OBSIGATE_GITEA_URL": "https://example.invalid",
"MISTRAL_API_KEY": "",
}
),
encoding="utf-8",
)
def test_apercu_import_purge(self, client, env_coffre):
self._ecrire_api_keys(env_coffre)
resp = client.get("/api/coffres/migration/api-keys")
assert resp.status_code == 200, resp.text
# Aperçu = métadonnées seules, jamais une valeur.
assert VALEUR_TEST not in resp.text
assert VALEUR_TEST_2 not in resp.text
candidats = {c["nom"]: c for c in resp.json()["candidats"]}
assert candidats["deepseek_api_key"]["has_value"] is True
assert candidats["mistral_api_key"]["has_value"] is False
assert "obsigate_gitea_url" not in candidats # URL, pas un secret
# Import : ne supprime rien dans api_keys.json.
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 200, resp.text
resultat = resp.json()
assert resultat["coffre"] == "obsigate-interne"
assert "deepseek_api_key" in resultat["migres"]
assert "obsigate_gitea_token" in resultat["migres"]
assert "mistral_api_key" in resultat["sans_valeur"]
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST
# Les secrets migrés sont visibles en métadonnées et distribuables.
resp = client.get("/api/coffres/obsigate-interne/secrets")
assert resp.status_code == 200
assert VALEUR_TEST not in resp.text
assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json())
_, jeton, _ = _creer_consommateur_octroi(
client, slug="obsigate-interne", nom="deepseek_api_key"
)
resp = _distribuer(
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
# Purge sans confirmation : refusée, fichier intact.
resp = client.post(
"/api/coffres/migration/api-keys/purger",
json={"confirmer": False, "noms": ["deepseek_api_key"]},
)
assert resp.status_code == 400
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert "DEEPSEEK_API_KEY" in cles
# Purge confirmée : seules les clés listées disparaissent.
resp = client.post(
"/api/coffres/migration/api-keys/purger",
json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]},
)
assert resp.status_code == 200, resp.text
assert set(resp.json()["supprimes"]) == {
"deepseek_api_key",
"obsigate_gitea_token",
}
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert "DEEPSEEK_API_KEY" not in cles
assert "OBSIGATE_GITEA_TOKEN" not in cles
assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid"
# Audit sans valeur.
_fichiers_sans_valeur(env_coffre)
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
assert "migration_api_keys" in audit
assert "migration_api_keys_purge" in audit
def test_import_ne_ecrase_pas_existant(self, client, env_coffre):
self._ecrire_api_keys(env_coffre)
assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200
(env_coffre / "api_keys.json").write_text(
json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8"
)
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 200
assert "deepseek_api_key" in resp.json()["deja_existants"]
_, jeton, _ = _creer_consommateur_octroi(
client, slug="obsigate-interne", nom="deepseek_api_key"
)
resp = _distribuer(
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
)
assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée
def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch):
# Une clé à migrer est nécessaire : sans valeur à chiffrer,
# l'import réussit trivialement même sans KEK (mode dégradé).
self._ecrire_api_keys(env_coffre)
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 503
assert resp.json()["detail"] == "kek_absente"
# ---------------------------------------------------------------------------
# Phase 2 — Restauration sur instance vierge (issue #20)
# ---------------------------------------------------------------------------
class TestRestaurationVierge:
def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
# Simulation d'une instance vierge : seuls les deux fichiers sont
# copiés, la KEK (variable d'environnement) est inchangée.
restaure = tmp_path / "restaure"
restaure.mkdir()
for nom in ("coffres.json", "coffres_valeurs.json"):
shutil.copy(env_coffre / nom, restaure / nom)
coffre_r = get_coffre_or_404("personnel", restaure)
assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST
for nom in ("coffres.json", "coffres_valeurs.json"):
assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8")
def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
restaure = tmp_path / "restaure"
restaure.mkdir()
for nom in ("coffres.json", "coffres_valeurs.json"):
shutil.copy(env_coffre / nom, restaure / nom)
# Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur.
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
coffre_r = get_coffre_or_404("personnel", restaure)
with pytest.raises(TagInvalideError):
distribuer_valeur(coffre_r, secret, restaure)
# ---------------------------------------------------------------------------
# Phase 3 — Contexte Spark imbriqué (issue #21)
# ---------------------------------------------------------------------------
class TestDistributionSpark:
def test_contexte_nested_mission(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.post(
"/api/coffres/distribuer",
json={
"reference": "coffre:personnel/gitea-token",
"contexte": {"type": "mission", "id": "mission-test-2"},
},
headers={"Authorization": f"Bearer {jeton}"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
def test_contexte_nested_sans_octroi_acces_requis(self, client):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
jeton = resp.json()["jeton"]
resp = client.post(
"/api/coffres/distribuer",
json={
"reference": "coffre:personnel/gitea-token",
"contexte": {"type": "mission", "id": "mission-test-3"},
},
headers={"Authorization": f"Bearer {jeton}"},
)
# Côté Spark, ce code se traduit par l'état acces_requis.
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_absent"
# ---------------------------------------------------------------------------
# Phase 4 — Consommateur + octrois en un appel (issue #22)
# ---------------------------------------------------------------------------
class TestConsommateurAvecOctrois:
def test_avec_octrois_distribue(self, client, env_coffre):
_creer_coffre_secret(client)
resp = client.post(
"/api/coffres/consommateurs/avec-octrois",
json={
"nom": "tiktak",
"octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}],
},
)
assert resp.status_code == 200, resp.text
corps = resp.json()
assert corps["nom"] == "tiktak"
assert corps["jeton"].startswith("csk_")
assert len(corps["octrois"]) == 1
assert corps["erreurs"] == []
resp = _distribuer(client, corps["jeton"])
assert resp.status_code == 200
assert resp.json()["valeur"] == VALEUR_TEST
liste = client.get("/api/coffres/consommateurs").json()
assert corps["jeton"] not in json.dumps(liste)
assert "jeton_hash" not in json.dumps(liste)
def test_avec_octrois_erreur_signalee_sans_annuler(self, client):
_creer_coffre_secret(client)
resp = client.post(
"/api/coffres/consommateurs/avec-octrois",
json={
"nom": "flowdeck",
"octrois": [
{"coffre_slug": "personnel", "secret_nom": "gitea-token"},
{"coffre_slug": "inexistant", "secret_nom": "gitea-token"},
],
},
)
assert resp.status_code == 200, resp.text
assert len(resp.json()["octrois"]) == 1
assert len(resp.json()["erreurs"]) == 1
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
+17
View File
@@ -129,3 +129,20 @@ def test_rail_mobile_vers_le_vrai_hamburger():
assert 'matchMedia("(max-width: 768px)")' in html assert 'matchMedia("(max-width: 768px)")' in html
assert '"hamburger-btn"' in html assert '"hamburger-btn"' in html
assert '"sidebar-toggle-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
+3 -3
View File
@@ -2,7 +2,7 @@
Validates: Validates:
- Theme JSON structure validity - Theme JSON structure validity
- All 15 built-in themes have required keys and valid CSS colors - All 16 built-in themes have required keys and valid CSS colors
- High-contrast and sepia mode generators produce valid CSS vars - High-contrast and sepia mode generators produce valid CSS vars
- Import/export round-trip - Import/export round-trip
- AVAILABLE_MODES includes all 4 modes - AVAILABLE_MODES includes all 4 modes
@@ -80,9 +80,9 @@ class TestThemeStructure:
assert '_genSepia' in self.content assert '_genSepia' in self.content
def test_theme_count(self): def test_theme_count(self):
"""Should have 15 built-in themes.""" """Should have 16 built-in themes."""
names = _extract_theme_names(self.content) names = _extract_theme_names(self.content)
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}" assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
def test_required_export_functions(self): def test_required_export_functions(self):
"""All required functions should be exported.""" """All required functions should be exported."""