Compare commits

...
38 Commits
Author SHA1 Message Date
bruno 045abd6a3c Merge pull request 'ObsiGate 2.86.1 — barre v0.6.0 : entrée « Administration » du menu compte (fusion #142)' (#72) from menu-compte-admin-v060 into main
CI / lint (push) Successful in 1m19s
CI / security (push) Successful in 19s
CI / test (push) Successful in 3m37s
CI / build (push) Successful in 11s
CI / e2e (push) Failing after 15m5s
2026-11-06 15:27:43 -05:00
Spark 06855937bd feat: barre v0.6.0 — entrée « Administration » du menu compte affirmée par le rôle global (fusion #142)
CI / lint (pull_request) Successful in 1m31s
CI / security (pull_request) Successful in 29s
CI / test (pull_request) Successful in 3m40s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Failing after 14m47s
2026-11-06 20:21:31 +00:00
bruno 854ff0f904 Merge pull request 'ObsiGate 2.86.0 — BUG-122 : déconnexion globale navigable GET /sso/logout (fusion #141)' (#71) from fix/deconnexion-globale-bug-122 into main
CI / lint (push) Successful in 1m21s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m40s
CI / build (push) Successful in 10s
CI / e2e (push) Failing after 15m8s
2026-11-06 14:26:43 -05:00
Spark 5a5d7a5c59 fix: brancher aussi la barre d'ObsiGate sur /sso/logout (BUG-122, fusion #141)
CI / lint (pull_request) Successful in 1m21s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m37s
CI / build (pull_request) Successful in 11s
CI / e2e (pull_request) Failing after 17m11s
2026-11-06 19:07:29 +00:00
Spark b8949b2ddc fix: point d'entrée navigable de la déconnexion globale GET /sso/logout (BUG-122, fusion #141) — 2.86.0
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m40s
CI / build (pull_request) Successful in 32s
CI / e2e (pull_request) Failing after 15m40s
2026-11-06 18:56:04 +00:00
bruno 15d015e59b Merge pull request 'ObsiGate 2.85.1 — HUB-19 : propagation des rôles du Hub (consommatrice, registre D3(a)) (#228)' (#69) from hub19-roles-obsigate-2-85-1 into main
CI / lint (push) Successful in 1m14s
CI / security (push) Successful in 19s
CI / test (push) Successful in 3m34s
CI / build (push) Successful in 11s
CI / e2e (push) Failing after 15m26s
2026-11-06 11:31:25 -05:00
Spark dcf847e2fd feat: HUB-19 — rôles du Hub consommés par ObsiGate (registre D3(a)) #228
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 21s
CI / test (pull_request) Failing after 3m39s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-11-06 16:30:30 +00:00
bruno a3f76e9b0c Merge pull request 'ObsiGate 2.85.0 — HUB-22 : API machine des sessions draco (liste et révocation, #227)' (#68) from hub22-sessions-machine into main
CI / lint (push) Failing after 11s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 24s
2026-11-06 11:25:26 -05:00
Spark c157a22668 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227)
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m34s
CI / build (pull_request) Successful in 17s
CI / e2e (pull_request) Failing after 16m29s
2026-11-06 11:08:04 -05:00
Spark cc3707be7a feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:08:01 -05:00
Spark 75c6f61235 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:58 -05:00
Spark 8d7692c6df feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:55 -05:00
Spark 318a20c652 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:51 -05:00
Spark 006cbeed64 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:46 -05:00
Spark 92ad47fc99 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:43 -05:00
Spark 84648591ea feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:39 -05:00
Spark 308f99cf63 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:35 -05:00
Spark 3bfda066ab feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:31 -05:00
Spark 0230f4fa72 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:30 -05:00
Spark 9f6f366870 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:27 -05:00
Spark 44a8154cae feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:24 -05:00
Spark 222cd0b562 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:22 -05:00
Spark 5127510509 revert: HUB-22 — retrait du fichier poussé par erreur sur main (le PUT Contents a ignoré new_branch_name ; la livraison repart en branche hub22-sessions-machine, #227)
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 16s
CI / test (push) Successful in 4m42s
CI / build (push) Successful in 13s
CI / e2e (push) Failing after 14m59s
2026-11-06 11:06:56 -05:00
Spark 3876f0e9c3 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227)
CI / lint (push) Successful in 1m18s
CI / security (push) Successful in 16s
CI / test (push) Successful in 3m35s
CI / build (push) Successful in 14s
CI / e2e (push) Failing after 15m32s
2026-11-06 11:06:00 -05:00
bruno c8d2eb19db Merge pull request 'ObsiGate 2.84.1 — HUB-18 : thème commun, barre v0.5.0 + initialisation thème Hub (fusion #132)' (#67) from hub18-theme-commun into main
CI / lint (push) Successful in 1m34s
CI / security (push) Successful in 28s
CI / test (push) Successful in 4m49s
CI / build (push) Successful in 10s
CI / e2e (push) Failing after 14m54s
2026-11-06 09:45:58 -05:00
Spark 2943e40e81 feat: HUB-18 thème commun — barre v0.5.0 + init thème Hub (2.84.1, fusion #132)
CI / lint (pull_request) Successful in 1m16s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m38s
CI / build (pull_request) Successful in 13s
CI / e2e (pull_request) Failing after 15m2s
2026-11-06 14:22:45 +00:00
bruno 128deb248d Merge pull request 'ObsiGate 2.84.0 — HUB-13 : API d'administration des comptes machine-à-machine (fusion #127)' (#66) from hub13-api-admin-comptes-2-84-0 into main
CI / lint (push) Successful in 1m15s
CI / security (push) Successful in 15s
CI / test (push) Successful in 3m45s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 14m48s
2026-11-06 08:40:24 -05:00
Spark 151d9faeab chore: relance de la vérification de la PR (commit vide, aucun changement de code)
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m46s
CI / build (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 15m53s
2026-11-06 13:34:29 +00:00
bruno c335f57ea2 Merge PR #66 : ObsiGate 2.84.0 — HUB-13 API admin des comptes (go Bruno)
CI / lint (push) Successful in 1m21s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m49s
CI / build (push) Successful in 15s
CI / e2e (push) Successful in 15m8s
2026-11-05 22:30:21 -05:00
Spark c27fd977a8 ObsiGate 2.84.0 : version et changelog (HUB-13, fusion #127) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m23s
CI / security (pull_request) Successful in 23s
CI / test (pull_request) Successful in 3m34s
CI / build (pull_request) Successful in 1m32s
CI / e2e (pull_request) Successful in 15m8s
2026-11-06 00:58:19 +00:00
Spark 65d01f91b8 ObsiGate 2.84.0 : API d'administration des comptes machine-à-machine (HUB-13, fusion #127, #226) — routes /api/admin/machine/comptes protégées par DRACO_ADMIN_MACHINE_KEY, révocation des jti draco à la désactivation, must_change_password, journalisation d'audit 2026-11-06 00:58:16 +00:00
bruno 0427c00015 Merge pull request 'ObsiGate 2.83.5 — barre v0.4.1 : correctif des icônes invisibles du menu compte (#125)' (#65) from hub12-barre-v041 into main
CI / lint (push) Successful in 2m28s
CI / security (push) Successful in 25s
CI / test (push) Successful in 3m34s
CI / build (push) Successful in 9s
CI / e2e (push) Successful in 16m51s
2026-11-05 02:09:20 -05:00
Spark b83de53447 ObsiGate 2.83.5 : barre v0.4.1 — menu compte resserré à 232 px, re-vendange verbatim (fusion #125)
CI / lint (pull_request) Successful in 1m21s
CI / security (pull_request) Successful in 28s
CI / test (pull_request) Successful in 5m18s
CI / build (pull_request) Successful in 15s
CI / e2e (pull_request) Successful in 16m40s
2026-11-05 07:08:02 +00:00
Spark 5eba3e2558 ObsiGate 2.83.5 : version et changelog (barre v0.4.1, fusion #125) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m57s
CI / build (pull_request) Successful in 10s
CI / e2e (pull_request) Successful in 17m0s
2026-11-05 05:53:14 +00:00
Spark 10aa88e03c ObsiGate 2.83.5 : barre v0.4.1 — correctif des icônes invisibles du menu compte, vendange verbatim (fusion #125) + garde 2026-11-05 05:53:05 +00:00
bruno e0e0f9af0f Merge pull request 'ObsiGate 2.83.4 — barre v0.4.0 : icônes monoline dans le menu compte (HUB-11)' (#64) from hub11-barre-v040 into main
CI / lint (push) Successful in 1m24s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m35s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 16m38s
2026-11-05 00:33:34 -05:00
Spark 35b828a4c5 ObsiGate 2.83.4 : version et changelog (barre v0.4.0, HUB-11) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m19s
CI / security (pull_request) Successful in 21s
CI / test (pull_request) Successful in 3m37s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Successful in 16m51s
2026-11-05 05:03:01 +00:00
Spark 6ebb3b3eae ObsiGate 2.83.4 : barre v0.4.0 — icônes monoline du menu compte, vendange verbatim (HUB-11) + garde 2026-11-05 05:02:10 +00:00
32 changed files with 2659 additions and 29 deletions
+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).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.83.3**.
> [Unreleased](#unreleased). La dernière version livrée est **2.86.1**.
---
@@ -14,6 +14,51 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.86.1] — 2026-11-06
- **Barre commune v0.6.0 — entrée « Administration » du menu compte** (fusion #142, demande de Bruno) : barre re-vendangée **verbatim** depuis dracodev-commons (PR commons #14) — `frontend/draco-barre.js` et `frontend/draco-barre.css`. Le menu compte gagne un cinquième lien Hub, « Administration » → `<hub>/admin` (le Hub redirige vers `/admin/utilisateurs`), après le séparateur et avant « Déconnexion », à **visibilité conditionnelle** : l'entrée n'apparaît que si l'hôte affirme que le compte courant est administrateur de suite (`DRACO_BAR.setAdmin`). Branchement hôte : `frontend/index.html` affirme le statut depuis la réponse de `/api/auth/me` déjà chargée pour l'avatar — `setAdmin(u.role === "admin")`. Le rôle global du compte chez ObsiGate est la définition même de l'administrateur de suite (c'est lui que la garde `/admin/*` du Hub vérifie, via ce même endpoint) : ObsiGate le connaît nativement. Tout autre rôle, ou échec de l'appel : aucune affirmation, l'entrée reste masquée. Garde : `tests/test_draco_barre.py` (`test_barre_v060_entree_administration`).
---
## [2.86.0] — 2026-11-06
- **BUG-122 — Déconnexion globale navigable : `GET /sso/logout`** (fusion #141) : le bouton « Déconnexion » du menu compte de la barre commune ne faisait rien dans les applications sœurs — la barre relaie l'action à l'hôte, et la déconnexion globale d'ObsiGate (ACC-3.1, #216) n'était appelable qu'en same-origin depuis l'interface d'ObsiGate (`POST /api/auth/logout`). Nouvelle route `GET /sso/logout?return_to=`, symétrique de `/sso/authorize` : elle exécute la déconnexion globale — logique extraite dans `apply_global_logout()` (`backend/auth/router.py`), désormais partagée par les deux entrées (jetons natifs refresh/access révoqués, `jti` du cookie `draco_session` révoqué, cookie effacé sur le domaine parent) — puis redirige vers le `return_to` validé par la liste blanche existante (`validate_return_to` : https + `*.dracodev.net`, repli « / »). Idempotente sans session. Les applications sœurs branchent leur action `deconnexion` sur cette route (leur session locale est détruite d'abord chez celles qui en ont une) — et la barre d'ObsiGate elle-même aussi (`frontend/index.html`, retour à sa racine). Tests : `tests/test_draco_session.py` (+3 : révocation et redirection, liste blanche, idempotence), `tests/test_draco_barre.py` (+1 : branchement de la barre d'ObsiGate). Fiche : `docs/features/deconnexion-globale-bug-122.md`.
---
## [2.85.1] — 2026-11-06
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
## [2.85.0] — 2026-11-06
- **HUB-22 — API machine des sessions draco (vague C du plan Hub v2.1, #227)** : trois nouvelles routes sous la garde machine existante (`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` / `X-Draco-Actor` ; 401 sans clé valide, 404 si l'API n'est pas configurée ou le compte inconnu) — `GET /api/admin/machine/comptes/{username}/sessions` liste les sessions actives du compte (`jti`, `exp`, `expire_le` en ISO 8601 UTC, `revoquee`), `POST …/sessions/{jti}/revoquer` révoque une session (404 si le `jti` n'est pas dans l'ensemble actif de ce compte — jamais de révocation cross-utilisateur) et `POST …/sessions/revoquer-toutes` révoque toutes les sessions (déconnexion globale, **sans désactiver le compte**). Backend : `draco_session.issued_sessions_for_user()` (sessions actives triées par expiration, purge des expirés au passage) et `revoke_one_for_user()` (appartenance au `sub` vérifiée avant révocation), sur le registre des émissions de HUB-13 et la même clé d'identité (`id`, à défaut `username`). Écritures journalisées dans `data/audit.log` (`session_revoquee`, `sessions_revoquees`). Fiche : `docs/features/hub-sessions-machine-227.md`. Tests : `tests/test_comptes_machine.py` (`TestSessions`), `tests/test_draco_session.py`.
---
## [2.84.1] — 2026-11-06
- **HUB-18 — Thème commun (fusion #132)** : barre commune re-vendangée **verbatim** en **v0.5.0** depuis dracodev-commons (branche `hub18-theme-commun`, PR commons #12) — `frontend/draco-barre.js` seul change côté barre (CSS et fragment inchangés). La barre apprend la préférence de thème du profil du Hub (`GET <hub>/api/profil/theme`, cookie draco ; `<hub>` = URL résolue de la barre, jamais figée) et l'applique à l'ouverture : cache `localStorage` `draco-theme-hub` (TTL 12 h) appliqué synchrone, puis fetch asynchrone non bloquant ; `sombre`/`clair` posent `data-theme`, `systeme` le retire (`prefers-color-scheme` décide). **Repli intégral** si le Hub ne répond pas, répond non-OK ou `theme: null` : le comportement actuel (choix local, puis système) reste seul maître. Initialisation propre à ObsiGate dans `frontend/app.js` : `initTheme()` écoute l'événement `draco:theme` de la barre et route le mode effectif par `applyTheme()` — icône et libellé du bouton de thème et feuilles highlight.js restent synchronisés, le mode appliqué devient le choix local mémorisé (`obsigate-theme`), et le bouton de bascule reste fonctionnel pour la session. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.84.0] — 2026-11-06
- **HUB-13 — API d'administration des comptes, machine-à-machine (fusion #127, #226)** : nouvelles routes `/api/admin/machine/comptes` (liste, profil étendu, création, modification, désactivation/réactivation, suppression, réinitialisation du mot de passe et du MFA), protégées par la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, distincte de `DRACO_SSO_MACHINE_KEY` ; aucune session utilisateur ne les ouvre ; API inerte si la clé n'est pas configurée). Le profil étendu expose nom d'utilisateur, nom affiché, langue, avatar (valeur + présence), rôle global, état actif, MFA activé oui/non — jamais le secret TOTP ni le haché du mot de passe. La désactivation arrête l'émission de nouveaux jetons et révoque les `jti` draco actifs : `draco_session` tient désormais un registre des émissions (`data/draco_issued.json`) et `revoke_all_for_user()` révoque via la liste existante ; le repère `password_changed_at` (BUG-028) est aussi avancé. Nouveau champ utilisateur `must_change_password` : posé à la création machine et à la réinitialisation du mot de passe, signalé par la connexion et `GET /api/auth/me`, effacé au changement effectif. Chaque action est journalisée dans `data/audit.log` (acteur = identité de service `X-Draco-Actor`, action, compte cible, horodatage). Fiche : `docs/features/hub-api-admin-comptes-226.md`. Tests : `tests/test_comptes_machine.py`.
---
## [2.83.5] — 2026-11-05
- **Barre commune v0.4.1 — correctif des icônes invisibles du menu compte (fusion #125)** : seul `frontend/draco-barre.js` change, re-vendangé **verbatim** depuis dracodev-commons (branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11) ; le CSS est inchangé. En v0.4.0, `menuIcone()` créait le `<svg>` des icônes et ses primitives via `document.createElement()` — espace de noms **HTML**, jamais dessinés par le navigateur : les libellés du menu compte étaient décalés d'un cran d'icône, sans aucun glyphe. La v0.4.1 crée tout via `document.createElementNS()` (espace de noms SVG). Glyphes, CSS et comportements inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.83.4] — 2026-11-05
- **Barre commune v0.4.0 — icônes monoline du menu compte (HUB-11, fusion #125)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.4.0 (branche `menu-compte-icones-v0-4-0`, PR commons #10). Les items du menu compte — **Télécharger**, **À Propos**, **Aides**, **Profil**, **Déconnexion** — reçoivent des **icônes SVG monoline** (contour, trait fin, `currentColor`, ~18 px) dans le style des icônes du rail de la console Spark ; aucun emoji. L'en-tête d'identité reste sans icône ; les liens Hub et le relais « Déconnexion » sont inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.83.3] — 2026-11-04
- **Barre commune v0.3.0 — liens Hub du menu compte (HUB-1, fusion #115)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.3.0 (branche `hub-menu-compte-v0-3-0`, PR commons #9). Le menu compte gagne les liens **Télécharger**, **À Propos**, **Aides** et **Profil** (devenu un lien, plus un relais) vers le Hub. Branchement hôte : `backend/draco_config.py` gagne la clé `hub` (défaut officiel `https://hub.dracodev.net`, variable `DRACO_HUB_URL`, dérivation `-dev` → `hub-dev` depuis l'hôte de l'agent), servie dans `window.DRACO_URLS` ; `frontend/index.html` la passe en `urls.hub` de `DRACO_BAR_CONFIG`. « Déconnexion » reste relayée, inchangée. Aucun nouvel onglet : le Hub n'apparaît que dans le menu compte.
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.83.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.86.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.83.3).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.86.1).
---
*Projet : ObsiGate | Version : 2.83.3 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.86.1 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.83.3-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.86.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.83.3).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.86.1).
---
*Project: ObsiGate | Version: 2.83.3 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.86.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.83.3
2.86.1
+29
View File
@@ -154,6 +154,35 @@ def log_coffre_event(
_write_entry(entry)
def log_compte_admin(
acteur: str,
action: str,
cible: str,
ip: str | None = None,
details: dict | None = None,
):
"""Journal d'une action d'administration de compte (HUB-13, fusion #127).
``acteur`` est l'identité de service appelante (API machine des
comptes, en-tête ``X-Draco-Actor``), ``cible`` le compte visé.
Règle absolue : ``details`` ne contient jamais de mot de passe, de
secret TOTP ni de jeton — seulement des métadonnées (champs
modifiés, nombre de jti révoqués…). Le champ ``username`` reprend
l'acteur pour rester filtrable par l'écran d'audit existant.
"""
entry: dict = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"username": acteur,
"acteur": acteur,
"cible": cible,
"ip": ip or "unknown",
}
if details:
entry["details"] = details
_write_entry(entry)
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries.
+43 -1
View File
@@ -141,9 +141,26 @@ def _draco_fallback(request: Request) -> dict | None:
return user
def require_auth(current_user=Depends(get_current_user)):
def require_auth(request: Request, current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
# HUB-19 (#228) : la session draco a été refusée parce que le
# registre des rôles du Hub attribue le niveau « aucun » à ce
# compte pour ObsiGate — réponse explicite, distincte du 401
# générique. L'authentification native n'est pas concernée :
# ce refus ne naît que sur le chemin draco (user_for_draco).
if getattr(request.state, "role_hub_refuse", None) == "aucun":
raise HTTPException(
status_code=403,
detail=(
"Accès refusé : le registre des rôles du Hub attribue "
"le niveau « aucun » à votre compte pour ObsiGate, "
"l'accès par la session draco est donc refusé. "
"L'administrateur de la suite peut modifier ce niveau "
"dans la matrice des rôles du Hub. L'accès natif "
"d'ObsiGate (son propre identifiant) reste possible."
),
)
raise HTTPException(
status_code=401,
detail="Authentification requise",
@@ -159,6 +176,31 @@ def require_admin(current_user=Depends(require_auth)):
return current_user
def require_admin_hub(current_user=Depends(require_admin)):
"""Dependency: admin + garde du registre des rôles du Hub (HUB-19, #228).
Pour les routes d'administration des comptes (``/api/auth/admin/*``) :
quand la requête est authentifiée par le **canal draco** (``_draco_sub``
présent) et que le rôle du Hub est **connu** (``_hub_role`` non None),
le niveau ``administrateur`` du registre est exigé — le rôle local ne
suffit plus. Session native, ou rôle inconnu (Hub injoignable) :
règles actuelles inchangées.
"""
if current_user.get("_draco_sub"):
role_hub = current_user.get("_hub_role")
if role_hub is not None and role_hub != "administrateur":
raise HTTPException(
status_code=403,
detail=(
"Accès réservé au niveau « administrateur » d'ObsiGate "
"dans le registre des rôles du Hub (votre niveau actuel : "
f"« {role_hub} »). L'administrateur de la suite peut "
"l'accorder dans la matrice des rôles du Hub."
),
)
return current_user
def is_home_vault(vault_name: str) -> bool:
"""Un dossier personnel (#194) : vault « home-<user> »."""
return vault_name.startswith("home-")
+36 -12
View File
@@ -40,7 +40,7 @@ from .mfa import (
verify_recovery_code,
verify_totp,
)
from .middleware import is_auth_enabled, require_admin, require_auth
from .middleware import is_auth_enabled, require_admin_hub, require_auth
from .password import hash_password, validate_password_strength, verify_password
from .user_store import (
create_user,
@@ -298,6 +298,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
"role": user["role"],
"vaults": user["vaults"],
"avatar": user.get("avatar"),
# HUB-13 : mot de passe temporaire — changement forcé signalé.
"must_change_password": bool(user.get("must_change_password")),
},
}
@@ -373,12 +375,15 @@ async def refresh_token_endpoint(request: Request, response: Response):
}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
):
"""Logout: revoke refresh and access tokens, then delete cookies."""
def apply_global_logout(request: Request, response: Response) -> None:
"""Déconnexion globale (Fusion ACC-3.1, #216) appliquée à ``response``.
Révoque les jetons natifs (refresh + access, BUG-027), révoque le
``jti`` du cookie partagé ``draco_session`` et efface les cookies.
Partagée entre ``POST /api/auth/logout`` (réponse JSON) et
``GET /sso/logout`` (redirection, BUG-122) : les deux entrées
terminent exactement la même session, celle de la suite entière.
"""
refresh_tok = request.cookies.get("refresh_token")
if refresh_tok:
payload = decode_token(refresh_tok)
@@ -419,6 +424,15 @@ async def logout(
response.delete_cookie("refresh_token", path="/api/auth/refresh")
response.delete_cookie("access_token", path="/")
response.delete_cookie("access_token", path="/api") # just in case
@router.post("/logout")
async def logout(
request: Request,
response: Response,
):
"""Logout: revoke refresh and access tokens, then delete cookies."""
apply_global_logout(request, response)
return {"message": "Deconnecte avec succes"}
@@ -433,6 +447,8 @@ async def get_me(current_user=Depends(require_auth)):
"language": current_user.get("language", "fr"),
"last_login": current_user.get("last_login"),
"avatar": current_user.get("avatar"),
# HUB-13 : mot de passe temporaire — changement forcé signalé.
"must_change_password": bool(current_user.get("must_change_password")),
}
@@ -487,7 +503,11 @@ async def change_password(
assert user is not None, f"User {current_user['username']} not found"
if not verify_password(req.current_password, user["password_hash"]):
raise HTTPException(400, "Mot de passe actuel incorrect")
update_user(current_user["username"], {"password": req.new_password})
# HUB-13 : le changement effectif efface le marqueur de changement forcé.
update_user(current_user["username"], {
"password": req.new_password,
"must_change_password": False,
})
updated = get_user(current_user["username"])
result: dict = {"message": "Mot de passe mis à jour"}
if updated is not None:
@@ -949,9 +969,13 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
# ── Admin endpoints ───────────────────────────────────────────────────
# Garde `require_admin_hub` (HUB-19, #228) : `require_admin` + — pour
# une requête authentifiée par le canal draco dont le rôle du Hub est
# connu — exigence du niveau « administrateur » du registre des rôles.
# Sessions natives et rôle inconnu : règles actuelles inchangées.
@router.get("/admin/users")
async def list_users(admin=Depends(require_admin)):
async def list_users(admin=Depends(require_admin_hub)):
"""List all users (admin only). Password hashes are never included."""
return get_all_users()
@@ -959,7 +983,7 @@ async def list_users(admin=Depends(require_admin)):
@router.post("/admin/users")
async def create_user_endpoint(
req: CreateUserRequest,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Create a new user (admin only)."""
try:
@@ -986,7 +1010,7 @@ async def create_user_endpoint(
async def update_user_endpoint(
username: str,
req: UpdateUserRequest,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Update a user (admin only)."""
updates = req.dict(exclude_none=True)
@@ -999,7 +1023,7 @@ async def update_user_endpoint(
@router.delete("/admin/users/{username}")
async def delete_user_endpoint(
username: str,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Delete a user (admin only). Cannot delete own account."""
if username == admin["username"]:
+10 -1
View File
@@ -80,8 +80,16 @@ def create_user(
role: str = "user",
vaults: list[str] | None = None,
display_name: str | None = None,
must_change_password: bool = False,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
"""Create a new user. Raises ValueError if username already taken.
``must_change_password`` (HUB-13) marque un mot de passe temporaire
(création/réinitialisation par l'administration machine) : le
changement est forcé à la première connexion. Les comptes créés
avant ce champ n'en portent pas — ils valent ``False`` partout où
le champ est lu via ``.get(..., False)``.
"""
with _users_lock:
data = _read()
if username in data["users"]:
@@ -95,6 +103,7 @@ def create_user(
"role": role,
"vaults": vaults or [],
"active": True,
"must_change_password": bool(must_change_password), # HUB-13
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(UTC).isoformat(),
+157 -1
View File
@@ -19,6 +19,27 @@
# vide = cookie lié à l'hôte, utile en dev)
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
# liste de révocation (GET /api/sso/revocations)
#
# Registre des émissions (HUB-13, fusion #127) : chaque jti émis est
# consigné par `sub` dans data/draco_issued.json (jti -> exp, purgé à
# l'expiration) pour que l'administration des comptes puisse révoquer
# toutes les sessions draco d'un utilisateur (désactivation,
# suppression, réinitialisation du mot de passe) via revoke().
# HUB-22 (#227) ajoute la lecture de ce registre
# (issued_sessions_for_user) et la révocation unitaire contrôlée par
# appartenance (revoke_one_for_user), exposées par l'API machine des
# sessions (backend/routers/comptes_machine.py).
#
# Rôles du Hub (HUB-19, #228) : à l'ouverture de session draco,
# `user_for_draco` consulte en plus le registre des rôles du Hub
# (`backend/hub_roles.py`, contrat D3(a) posé par le pilote ShaarIt) :
# niveau connu ``aucun`` → la session draco est refusée (l'authenti-
# fication native d'ObsiGate, émettrice, n'est jamais touchée) ;
# niveau connu → il est joint au dict utilisateur (``_hub_role``)
# pour que les routes d'administration du canal draco exigent
# ``administrateur`` ; rôle inconnu (Hub injoignable) → comportement
# actuel, aucun verrouillage nouveau. Aucun rôle dans le jeton :
# validation locale, émission et révocations sont inchangées.
from __future__ import annotations
@@ -40,10 +61,12 @@ ALGORITHM = "HS256"
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
REVOKED_FILE = Path("data/draco_revoked.json")
ISSUED_FILE = Path("data/draco_issued.json")
ALLOWED_RETURN_SUFFIX = "dracodev.net"
_lock = threading.Lock()
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
_issued: dict[str, dict[str, int]] | None = None # sub -> {jti -> exp}
def _secret() -> str:
@@ -108,6 +131,115 @@ def revoked_list() -> list[dict]:
for jti, exp in _load_revoked().items() if exp > now]
# ── Registre des émissions (par utilisateur) ────────────────────────────
def _load_issued() -> dict[str, dict[str, int]]:
global _issued
if _issued is None:
try:
data = json.loads(ISSUED_FILE.read_text(encoding="utf-8"))
_issued = {
str(sub): {str(j): int(e) for j, e in tokens.items()}
for sub, tokens in data.items()
if isinstance(tokens, dict)
} if isinstance(data, dict) else {}
except (OSError, ValueError):
_issued = {}
return _issued
def _save_issued() -> None:
ISSUED_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = ISSUED_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_issued or {}), encoding="utf-8")
tmp.replace(ISSUED_FILE)
def _register_issued(sub: str, jti: str, exp: int) -> None:
"""Consigne un jti émis pour ``sub`` (purge des expirés au passage)."""
if not sub:
return
try:
with _lock:
store = _load_issued()
now = int(time.time())
for key in list(store.keys()):
store[key] = {j: e for j, e in store[key].items() if e > now}
if not store[key]:
del store[key]
store.setdefault(sub, {})[jti] = int(exp)
_save_issued()
except Exception: # le registre ne casse jamais une émission
logger.warning("Registre des émissions draco non écrit", exc_info=True)
def revoke_all_for_user(sub: str) -> int:
"""Révoque tous les jti draco actifs émis pour ``sub``.
Retourne le nombre de jti révoqués. Utilisé par l'administration
des comptes (HUB-13) : désactivation, suppression, réinitialisation
du mot de passe.
"""
if not sub:
return 0
with _lock:
store = _load_issued()
tokens = store.pop(str(sub), {})
now = int(time.time())
actifs = {j: e for j, e in tokens.items() if e > now}
if tokens:
_save_issued()
for jti, exp in actifs.items():
revoke(jti, exp)
return len(actifs)
def issued_sessions_for_user(sub: str) -> list[dict]:
"""Sessions draco actives émises pour ``sub`` (HUB-22, #227).
Retourne ``[{jti, exp, revoquee}]`` triée par ``exp`` croissant,
où ``revoquee`` indique si le ``jti`` figure déjà dans la liste des
révocations. Les entrées expirées de cet utilisateur sont purgées
du registre au passage (même patron que ``_register_issued``).
"""
if not sub:
return []
with _lock:
store = _load_issued()
now = int(time.time())
tokens = store.get(str(sub), {})
actifs = {j: e for j, e in tokens.items() if e > now}
if len(actifs) != len(tokens):
if actifs:
store[str(sub)] = actifs
else:
store.pop(str(sub), None)
_save_issued()
revoques = _load_revoked()
entrees = sorted(actifs.items(), key=lambda kv: kv[1])
return [{"jti": jti, "exp": exp, "revoquee": jti in revoques}
for jti, exp in entrees]
def revoke_one_for_user(sub: str, jti: str) -> bool:
"""Révoque un seul ``jti`` draco de ``sub`` (HUB-22, #227).
Ne révoque (via ``revoke``) que si le ``jti`` appartient bien à
l'ensemble émis de ce ``sub`` et n'est pas expiré — jamais de
révocation d'une session d'un autre utilisateur. Retourne ``True``
si la révocation a eu lieu, ``False`` sinon.
"""
if not sub or not jti:
return False
with _lock:
store = _load_issued()
exp = store.get(str(sub), {}).get(jti)
if exp is None or exp <= int(time.time()):
return False
revoke(jti, exp)
return True
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
@@ -122,7 +254,9 @@ def issue_token(user: dict) -> tuple[str, str, int]:
"exp": exp,
"jti": jti,
}
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
token = jwt.encode(claims, _secret(), algorithm=ALGORITHM)
_register_issued(str(claims["sub"]), jti, exp)
return token, jti, exp
def decode_claims(token: str) -> dict | None:
@@ -211,6 +345,28 @@ def user_for_draco(request) -> dict | None:
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
# HUB-19 (#228) : rôle du registre du Hub pour ce porteur.
# Niveau connu « aucun » → pas de session établie par draco :
# le refus est consigné sur la requête pour que `require_auth`
# réponde un message explicite en français (et non un simple
# 401 générique). Rôle connu → joint au dict (`_hub_role`) ;
# rôle inconnu → `_hub_role` vaut None et le comportement
# actuel s'applique, sans verrouillage nouveau.
from backend import hub_roles
cookie = ""
try:
cookie = request.headers.get("cookie", "") or ""
except Exception: # un objet requête minimal (tests) ne casse rien
cookie = ""
role = hub_roles.role_pour(sub, cookie)
if role == "aucun":
try:
request.state.role_hub_refuse = "aucun"
except Exception: # pas d'état mutable : refus quand même
pass
return None
user["_hub_role"] = role
return user
return None
+126
View File
@@ -0,0 +1,126 @@
# backend/hub_roles.py
# Hub HUB-19 (propagation, #228) — Rôles par application : consommation
# du registre du Hub par ObsiGate, sur le contrat posé par l'hôte
# pilote ShaarIt (0.7.0, décision D3(a) du plan Hub v2).
#
# À l'ouverture de session draco (validation locale du cookie partagé,
# `draco_session.user_for_draco`), ObsiGate demande au Hub le niveau
# du porteur pour l'application `obsigate` :
# ``GET {hub}/api/roles/obsigate``, en relayant l'en-tête Cookie
# complet de la requête, ``Accept: application/json``, délai 2 s.
# Réponse 200 : ``{"app", "sub", "role", "explicite",
# "ttl_secondes": 60}``, rôle ∈ {aucun, utilisateur, administrateur}.
#
# Cache mémoire par `sub`, TTL 60 s, sur le patron du pilote (et du
# cache de révocations draco) : l'horodatage est posé **avant**
# l'appel — un échec conserve l'ancien cache et ne déclenche pas de
# rafale de requêtes vers un Hub en panne.
#
# **Repli obligatoire** : Hub injoignable, en erreur ou réponse
# illisible → rôle **inconnu** (``None``) et l'appelant garde son
# comportement actuel. Le registre ne crée jamais un verrouillage
# nouveau.
#
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
# la barre commune (#223) : variable explicite `DRACO_HUB_URL` d'abord,
# dérivation par l'hôte sœur (suffixe d'environnement de l'hôte de
# l'agent) ensuite, défaut officiel enfin. Jamais d'URL dev figée.
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.request
logger = logging.getLogger("obsigate.hub_roles")
#: Identifiant d'ObsiGate dans le registre des applications du Hub.
APP = "obsigate"
#: Niveaux reconnus du registre (contrat D3(a)).
ROLES = ("aucun", "utilisateur", "administrateur")
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
CACHE_TTL_SECONDS = 60
#: Délai maximal de la consultation du Hub (contrat : 2 s).
DELAI_SECONDS = 2
_lock = threading.Lock()
_cache: dict[str, str] = {} # sub -> rôle connu
_cache_at: float | None = None # horodatage (monotone) du dernier appel
def _hub_url() -> str:
"""Base publique du Hub, résolue comme pour la barre commune."""
from backend.draco_config import app_url
return app_url("hub")
def _consulter_hub(url: str, cookie: str) -> str | None:
"""Appelle le Hub et retourne le rôle validé, ou ``None``.
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON illisible,
rôle hors contrat) retourne ``None`` : rôle inconnu, repli.
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
quel — c'est lui qui prouve l'identité du porteur au Hub.
"""
requete = urllib.request.Request(
url,
method="GET",
headers={"Cookie": cookie, "Accept": "application/json"},
)
try:
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
if reponse.status != 200:
return None
donnees = json.loads(reponse.read().decode("utf-8"))
except Exception: # le registre ne casse jamais une ouverture de session
return None
if not isinstance(donnees, dict):
return None
role = donnees.get("role")
return role if role in ROLES else None
def role_pour(sub: str, cookie: str) -> str | None:
"""Niveau connu pour ce `sub` : un des ``ROLES`` si le Hub a
répondu, ``None`` si le rôle est inconnu (Hub injoignable/erreur)
— repli = comportement actuel de l'appelant.
`cookie` est l'en-tête Cookie complet de la requête d'origine,
relayé tel quel au Hub.
"""
global _cache_at
if not sub:
return None
with _lock:
if _cache_at is not None \
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS \
and sub in _cache:
return _cache[sub]
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
# cache reste en vigueur, un échec ne déclenche pas de rafale.
_cache_at = time.monotonic()
url = f"{_hub_url().rstrip('/')}/api/roles/{APP}"
role = _consulter_hub(url, cookie)
if role is None:
logger.warning(
"Registre des rôles du Hub indisponible (%s) — "
"comportement actuel conservé", url)
return None
with _lock:
_cache[sub] = role
_cache_at = time.monotonic()
return role
def reinitialiser_cache() -> None:
"""Vide le cache des rôles (tests, redémarrage à chaud)."""
global _cache_at
with _lock:
_cache.clear()
_cache_at = None
+2
View File
@@ -550,6 +550,7 @@ from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_router
from backend.routers.backups import router as backups_router
from backend.routers.coffres import router as coffres_router
from backend.routers.comptes_machine import router as comptes_machine_router
from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
@@ -585,6 +586,7 @@ app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
app.include_router(config_router) # ROADMAP #85 T7 — Config
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
app.include_router(comptes_machine_router) # HUB-13 (fusion #127) — Admin des comptes, machine-à-machine
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
+424
View File
@@ -0,0 +1,424 @@
# backend/routers/comptes_machine.py
# HUB-13 (fusion #127) — API d'administration des comptes, machine-à-machine.
#
# Le Hub (interface d'administration des utilisateurs) administre les
# comptes dont ObsiGate reste le magasin (décisions D1(a)/D2(a) du plan
# Hub v2) via ces routes, en lecture et en écriture : lister, lire le
# profil étendu, créer, modifier, désactiver/réactiver, supprimer,
# réinitialiser le mot de passe et le MFA.
#
# HUB-22 (#227, vague C du plan Hub v2.1) ajoute, sous la même garde et
# avec la même journalisation, les routes « sessions » : lister les
# sessions draco actives d'un compte, en révoquer une (par son `jti`,
# jamais celle d'un autre compte) ou toutes (déconnexion globale, sans
# désactiver le compte), via le registre des émissions de HUB-13.
#
# Emplacement : ``/api/admin/machine/comptes`` — dans l'espace
# d'administration existant (``/api/admin/…``), avec un segment
# ``machine`` explicite qui le distingue des routes d'administration par
# session (``/api/auth/admin/users``, garde ``require_admin``). Aucune
# session utilisateur — même administratrice — n'ouvre ces routes :
# seule la clé machine dédiée les protège.
#
# Authentification machine (même famille que ``GET /api/sso/revocations``) :
# DRACO_ADMIN_MACHINE_KEY clé dédiée à cette API (en-tête
# ``X-Draco-Machine-Key``). Distincte de
# ``DRACO_SSO_MACHINE_KEY`` : le périmètre
# (écriture de comptes) est plus sensible que
# la seule lecture des révocations (plan §3).
# Sans elle, l'API est inerte (404, comme la
# liste de révocations non configurée).
# X-Draco-Actor identité de service appelante, facultative
# (défaut ``service-machine``) — consignée
# comme acteur dans le journal d'audit.
#
# Chaque action est journalisée côté ObsiGate dans le journal d'audit
# existant (``data/audit.log``, voir backend.audit.log_compte_admin).
from __future__ import annotations
import hmac
import logging
import os
import re
import time
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, validator
from backend import draco_session
from backend.audit import log_compte_admin
from backend.auth.password import validate_password_strength
from backend.auth.user_store import (
create_user,
delete_user,
get_all_users,
get_user,
update_user,
)
from backend.services.net import get_client_ip
logger = logging.getLogger("obsigate.comptes_machine")
router = APIRouter(prefix="/api/admin/machine/comptes", tags=["admin"])
#: Variable d'environnement portant la clé machine dédiée à cette API.
MACHINE_KEY_ENV = "DRACO_ADMIN_MACHINE_KEY"
#: En-tête de la clé machine (même nom que pour /api/sso/revocations).
MACHINE_KEY_HEADER = "X-Draco-Machine-Key"
#: En-tête facultatif : identité de service appelante (journal d'audit).
ACTOR_HEADER = "X-Draco-Actor"
#: Acteur consigné quand l'appelant ne se nomme pas.
DEFAULT_ACTOR = "service-machine"
# ── Garde machine ───────────────────────────────────────────────────────
def require_machine_admin(request: Request) -> str:
"""Exige la clé machine dédiée ; retourne l'identité de service.
Jamais de repli sur une session utilisateur : sans en-tête valide,
la réponse est 401 (ou 404 si l'API n'est pas configurée, patron de
``GET /api/sso/revocations``).
"""
machine_key = os.environ.get(MACHINE_KEY_ENV, "")
if not machine_key:
raise HTTPException(404, "Non configuré")
provided = request.headers.get(MACHINE_KEY_HEADER, "")
if not provided or not hmac.compare_digest(provided, machine_key):
raise HTTPException(401, "Clé machine invalide")
actor = (request.headers.get(ACTOR_HEADER, "") or "").strip()
return actor[:64] if actor else DEFAULT_ACTOR
# ── Profil étendu (liste blanche — jamais de secret) ────────────────────
def _profil(user: dict) -> dict:
"""Profil étendu d'un compte, sûr à exposer à un service.
Liste blanche stricte : ni ``password_hash``, ni secret TOTP
(``mfa_secret``/``mfa_secret_pending``), ni codes de récupération,
ni identifiants WebAuthn ne sortent — seul l'état MFA (oui/non)
est exposé. L'avatar suit le modèle existant : la valeur stockée
(data-URL ou ``None``, comme ``GET /api/auth/me``) plus le booléen
de présence ``avatar_present`` pour les listes légères.
"""
return {
"id": user.get("id"),
"username": user.get("username"),
"display_name": user.get("display_name"),
"language": user.get("language", "fr"),
"avatar": user.get("avatar"),
"avatar_present": bool(user.get("avatar")),
"role": user.get("role"),
"active": bool(user.get("active")),
"vaults": user.get("vaults", []),
"mfa_enabled": bool(user.get("mfa_enabled")),
"mfa_method": user.get("mfa_method"),
"must_change_password": bool(user.get("must_change_password")),
"created_at": user.get("created_at"),
"last_login": user.get("last_login"),
}
def _compte_ou_404(username: str) -> dict:
user = get_user(username)
if user is None:
raise HTTPException(404, "Compte introuvable")
return user
def _identifiant_sessions(user: dict) -> str:
"""Clé du registre des émissions draco pour un compte.
C'est le ``sub`` des jetons draco : l'identifiant du compte
(``id``), à défaut son nom d'utilisateur — exactement la clé
qu'utilise ``_revoquer_sessions`` depuis HUB-13.
"""
return str(user.get("id") or user.get("username") or "")
def _revoquer_sessions(user: dict) -> int:
"""Révoque les sessions d'un compte ; retourne le nombre de jti draco.
Les ``jti`` du cookie partagé émis pour ce compte sont révoqués via
le mécanisme existant (``draco_session.revoke``, exposé aux autres
applications par ``GET /api/sso/revocations``). Les jetons natifs
sont invalidés par le garde ``password_changed_at`` (BUG-028),
avancé par l'appelant (désactivation, réinitialisation du mot de
passe) ou par le drapeau ``active`` tant que le compte est inactif.
"""
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
def _audit(request: Request, acteur: str, action: str, cible: str,
details: dict | None = None) -> None:
log_compte_admin(acteur, action, cible,
ip=get_client_ip(request), details=details)
# ── Modèles de requête ──────────────────────────────────────────────────
class CreerCompteRequest(BaseModel):
username: str
password: str
display_name: str | None = None
role: str = "user"
vaults: list[str] = []
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
@validator("username")
def username_valid(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
raise ValueError("2-32 caractères alphanumériques, _ ou -")
return v.lower()
@validator("role")
def role_valid(cls, v):
if v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
class ModifierCompteRequest(BaseModel):
display_name: str | None = None
role: str | None = None
language: str | None = None
vaults: list[str] | None = None
@validator("role")
def role_valid(cls, v):
if v is not None and v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
@validator("language")
def language_valid(cls, v):
if v is not None and v not in ("fr", "en"):
raise ValueError("Langue invalide (fr | en)")
return v
class ReinitialiserMotDePasseRequest(BaseModel):
password: str
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
# ── Lecture ─────────────────────────────────────────────────────────────
@router.get("")
async def lister_comptes(acteur: str = Depends(require_machine_admin)):
"""Liste tous les comptes (profils étendus, sans aucun secret)."""
comptes = [_profil(u) for u in get_all_users()]
return {"comptes": comptes, "total": len(comptes)}
@router.get("/{username}")
async def lire_compte(username: str,
acteur: str = Depends(require_machine_admin)):
"""Lit un compte et son profil étendu."""
return _profil(_compte_ou_404(username))
# ── Écriture ────────────────────────────────────────────────────────────
@router.post("", status_code=201)
async def creer_compte(req: CreerCompteRequest, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Crée un compte. Le mot de passe initial est temporaire :
``must_change_password`` est posé (changement forcé signalé à la
première connexion, comme après une réinitialisation)."""
if get_user(req.username) is not None:
raise HTTPException(409, "Identifiant déjà utilisé")
try:
user = create_user(
req.username, req.password, req.role, req.vaults,
req.display_name, must_change_password=True,
)
except ValueError as e:
raise HTTPException(409, str(e)) from e
# #194 : dossier personnel — même provisionnement que la création
# par session administratrice (best effort).
from backend.user_home import ensure_user_home
await ensure_user_home(req.username)
fresh = get_user(req.username)
if fresh:
user = fresh
_audit(request, acteur, "compte_cree", req.username,
{"role": req.role})
return _profil(user)
@router.patch("/{username}")
async def modifier_compte(username: str, req: ModifierCompteRequest,
request: Request,
acteur: str = Depends(require_machine_admin)):
"""Modifie un compte (nom affiché, rôle global, langue, vaults)."""
_compte_ou_404(username)
updates = req.dict(exclude_none=True)
if not updates:
raise HTTPException(400, "Aucun champ à modifier")
updated = update_user(username, updates)
_audit(request, acteur, "compte_modifie", username,
{"champs": sorted(updates.keys())})
return _profil(updated)
@router.post("/{username}/desactiver")
async def desactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Désactive un compte : plus aucun nouveau jeton n'est émis
(connexion refusée) et les ``jti`` draco actifs sont révoqués."""
user = _compte_ou_404(username)
updated = update_user(username, {
"active": False,
# BUG-028 : avancer le repère invalide aussi les jetons natifs
# déjà émis — ils ne ressuscitent pas à la réactivation.
"password_changed_at": time.time(),
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "compte_desactive", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reactiver")
async def reactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Réactive un compte désactivé (les anciennes sessions restent
révoquées : une nouvelle connexion est nécessaire)."""
_compte_ou_404(username)
updated = update_user(username, {"active": True})
_audit(request, acteur, "compte_reactive", username)
return _profil(updated)
@router.delete("/{username}")
async def supprimer_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Supprime un compte et révoque ses ``jti`` draco actifs."""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
try:
delete_user(username)
except ValueError as e:
raise HTTPException(404, str(e)) from e
# #194 : fermer le vault du dossier personnel (dossier conservé),
# comme la suppression par session administratrice.
from backend.user_home import release_user_home
await release_user_home(username)
_audit(request, acteur, "compte_supprime", username,
{"jti_revoques": nb_jti})
return {"message": f"Compte '{username}' supprimé"}
@router.post("/{username}/reinitialiser-mot-de-passe")
async def reinitialiser_mot_de_passe(
username: str, req: ReinitialiserMotDePasseRequest,
request: Request, acteur: str = Depends(require_machine_admin)):
"""Pose un nouveau mot de passe temporaire : changement forcé à la
première connexion (``must_change_password``, signalé par la
connexion et ``GET /api/auth/me``, effacé au changement effectif).
Les sessions existantes sont révoquées (repère BUG-028 + ``jti``
draco). Le mot de passe n'est jamais journalisé."""
user = _compte_ou_404(username)
updated = update_user(username, {
"password": req.password,
"must_change_password": True,
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "mot_de_passe_reinitialise", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reinitialiser-mfa")
async def reinitialiser_mfa(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Efface l'enrôlement TOTP d'un compte (utilisateur verrouillé
hors de son second facteur) : secret, secret en attente et codes
de récupération sont supprimés. Les clés WebAuthn éventuelles sont
conservées et gardent alors le MFA actif. Le secret n'est jamais
exposé — ni avant, ni après."""
user = _compte_ou_404(username)
webauthn_restantes = len(user.get("webauthn_credentials") or [])
updates: dict = {
"mfa_secret": None,
"mfa_secret_pending": None,
"mfa_recovery_codes": [],
}
if webauthn_restantes:
updates["mfa_method"] = "webauthn"
else:
updates["mfa_enabled"] = False
updates["mfa_method"] = None
updated = update_user(username, updates)
_audit(request, acteur, "mfa_reinitialise", username,
{"webauthn_conserves": webauthn_restantes})
return _profil(updated)
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
@router.get("/{username}/sessions")
async def lister_sessions(username: str,
acteur: str = Depends(require_machine_admin)):
"""Liste les sessions draco actives d'un compte.
Une session par ``jti`` émis et non expiré (registre des émissions
de HUB-13), triées par expiration croissante ; ``revoquee`` signale
un ``jti`` déjà présent dans la liste des révocations et
``expire_le`` est l'échéance ``exp`` en ISO 8601 UTC.
"""
user = _compte_ou_404(username)
sessions = [{
"jti": s["jti"],
"exp": s["exp"],
"expire_le": datetime.fromtimestamp(s["exp"], tz=UTC).isoformat(),
"revoquee": s["revoquee"],
} for s in draco_session.issued_sessions_for_user(
_identifiant_sessions(user))]
return {"username": username, "sessions": sessions}
@router.post("/{username}/sessions/{jti}/revoquer")
async def revoquer_session(username: str, jti: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque une seule session draco d'un compte, par son ``jti``.
404 si le ``jti`` ne figure pas dans l'ensemble actif de ce
compte : une session d'un autre utilisateur n'est jamais révoquée
par cette route (aucune révocation cross-utilisateur).
"""
user = _compte_ou_404(username)
if not draco_session.revoke_one_for_user(
_identifiant_sessions(user), jti):
raise HTTPException(404, "Session introuvable")
_audit(request, acteur, "session_revoquee", username, {"jti": jti})
return {"jti": jti, "revoquee": True}
@router.post("/{username}/sessions/revoquer-toutes")
async def revoquer_toutes_sessions(
username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque toutes les sessions draco actives d'un compte.
C'est la « déconnexion globale » : contrairement à la
désactivation, le compte reste actif — seule l'émission d'une
nouvelle session (nouvelle connexion) rouvre l'accès.
"""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "sessions_revoquees", username,
{"jti_revoques": nb_jti})
return {"revoquees": nb_jti}
+17
View File
@@ -11,6 +11,14 @@
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
#
# GET /sso/logout?return_to=<url> : symétrique de /sso/authorize —
# point d'entrée navigable de la déconnexion globale (BUG-122,
# fusion #141). Les autres applications y naviguent depuis leur menu
# compte ; la déconnexion exécutée est celle de POST /api/auth/logout
# (logique partagée : jetons natifs révoqués, jti draco révoqué,
# cookie effacé sur le domaine parent), puis redirection vers le
# return_to validé (même liste blanche ; repli « / »).
from __future__ import annotations
@@ -21,6 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from backend import draco_session
from backend.auth.router import apply_global_logout
router = APIRouter(tags=["SSO"])
@@ -34,6 +43,14 @@ async def sso_authorize(return_to: str = ""):
return RedirectResponse(url="/", status_code=302)
@router.get("/sso/logout")
async def sso_logout(request: Request, return_to: str = ""):
target = draco_session.validate_return_to(return_to)
response = RedirectResponse(url=target or "/", status_code=302)
apply_global_logout(request, response)
return response
@router.get("/api/sso/revocations")
async def sso_revocations(request: Request):
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.83.3"
version = "2.86.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.83.3"
version = "2.86.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.83.3",
"version": "2.86.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2
View File
@@ -227,6 +227,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
| *BUG-119* | Panneau Spark : le « mode console » (surimpression iframe de la console du service d'agents) est bloqué par le navigateur (« This content is blocked ») | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Panneau Spark → « Console ⤢ » : l'iframe reste sur la page d'erreur du navigateur | `frame-src 'self' blob: https://agent.dracodev.net` dans la CSP servie ; test `tests/test_security_headers.py::test_csp_frame_src_allows_agent_console` | Cause : `frame-src` n'autorisait pas l'origine du service d'agents, pourtant déjà présente dans `connect-src` (le panneau texte fonctionnait). Aucune autre directive modifiée. Redéploiement Docker requis |
| *BUG-120* | Menu inter-apps : sur l'environnement dev, les URL sœurs servies (`window.DRACO_URLS` : ObsiGate, Flowdeck, TikTak, Kanbante) restent en noms **prod** alors que l'URL de l'agent est bien en `-dev` | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/draco_config.py` (`app_url`, `_suffixe_environnement`) | Sur og-dev : `window.DRACO_URLS` donne `agent: https://agent-dev.dracodev.net` mais `tiktak: https://tiktak.dracodev.net` (prod) — le `.env` dev ne déclare pas les quatre `DRACO_<APP>_URL` | `backend/draco_config.py` : résolution sœur en 3 temps — explicite, puis **dérivation d'environnement** depuis l'hôte de `agent_url()` (`agent<SUFFIXE>.dracodev.net` → `https://<slug><SUFFIXE>.dracodev.net`, slugs `og`/`flowdeck`/`tiktak`/`kanbante` ; hors motif → rien), puis défaut officiel ; Gitea exclue. Tests `tests/test_draco_config.py` (+9 : dérivation `-dev` via `AGENT_EVENEMENTS_URL` et via `DRACO_AGENT_URL`, priorité de l'explicite, 5 hôtes hors motif, suffixe vide, injection) | Suite de #223 : seule l'URL de l'agent avait une dérivation de repli (`AGENT_EVENEMENTS_URL`) ; les sœurs retombaient sur les défauts officiels. Correctif de code demandé par Bruno : l'environnement se déduit de l'agent déjà configuré, sans variable supplémentaire. Redéploiement Docker requis |
| *BUG-122* | Déconnexion du menu compte inerte dans les applications sœurs : la barre commune relaie `deconnexion` à l'hôte, mais la déconnexion globale d'ObsiGate (ACC-3.1) n'est appelable qu'en same-origin (`POST /api/auth/logout`) — aucun point d'entrée navigable pour les autres hôtes | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/routers/sso.py` (`GET /sso/logout`), `backend/auth/router.py` (`apply_global_logout`) | Depuis la console Spark (ou Flowdeck/TikTak/ShaarIt/Hub) : menu compte → « Déconnexion » → rien ne se passe, la session draco reste vivante (vérifié en navigateur réel : cookie présent, `/api/v1/me` toujours 200) | Nouvelle route `GET /sso/logout?return_to=` : exécute la déconnexion globale (logique partagée `apply_global_logout` : jetons natifs refresh/access révoqués, `jti` draco révoqué, cookie effacé sur le domaine parent) puis redirige vers le `return_to` validé (liste blanche https + *.dracodev.net, repli `/`). Tests `tests/test_draco_session.py` (+3) | Signalé par Bruno le 2026-11-06 (capture console), carte fusion #141 ; les hôtes branchent leur action `deconnexion` sur cette route (PR sœurs). BUG-121 reste réservé au bug de couleur « Secrets » du vault Spark (proposé, non ordonné). Redéploiement Docker requis |
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|---|---|---|---|---|---|---|---|---|---|
@@ -361,6 +362,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
| 2026-10-23 | BUG-119 | Correction | `backend/main.py`, `tests/test_security_headers.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | **BUG-119 — le « mode console » du panneau Spark V3 (#222) était bloqué par le navigateur** (« This content is blocked ») : la console du service d'agents s'affiche dans une iframe `https://agent.dracodev.net`, mais la CSP servie par `SecurityHeadersMiddleware` limitait `frame-src` à `'self' blob:` — seul le panneau texte passait (`connect-src` autorisait déjà l'origine). Correctif strict : `frame-src 'self' blob: https://agent.dracodev.net`, toutes les autres directives inchangées à l'identique. Test de non-régression `test_csp_frame_src_allows_agent_console` (la CSP servie est analysée et la liste exacte des sources `frame-src` verrouillée). Vérifié : 100 passed (security_headers + csp_nonce + security_hardening + draco_barre + version), ruff propre sur les fichiers touchés (51 erreurs préexistantes ailleurs, inchangées), mypy 0 (120 fichiers) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-25 | BUG-120 (suite #223) | Correction | `backend/draco_config.py`, `tests/test_draco_config.py`, `.env.example`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/devprod-urls-config-223.md` | **BUG-120 — le menu inter-apps de la dev pointait la prod** : `window.DRACO_URLS` servait l'agent en `-dev` mais les quatre sœurs en noms officiels, le `.env` dev ne déclarant pas les `DRACO_<APP>_URL`. Correctif dans `draco_config.app_url()` : après l'explicite, **dérivation d'environnement** — hôte de `agent_url()` de la forme `agent<SUFFIXE>.dracodev.net` sans port → sœur en `<slug><SUFFIXE>.dracodev.net` ; hors motif (autre domaine, LAN, IP, port) aucune dérivation ; Gitea jamais dérivée. Vérifié : `test_draco_config.py` 16 passed, suite complète **1668 passed / 9 skipped**, ruff 0 et mypy 0 sur les fichiers touchés (51 erreurs ruff préexistantes ailleurs, inchangées) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-11-06 | BUG-122 (fusion #141) | Correction | `backend/routers/sso.py`, `backend/auth/router.py`, `tests/test_draco_session.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/features/deconnexion-globale-bug-122.md`, `VERSION` (+ dérivés) | **BUG-122 — la déconnexion du menu compte ne terminait rien hors ObsiGate** : ajout de `GET /sso/logout`, point d'entrée navigable de la déconnexion globale (logique partagée avec `POST /api/auth/logout`). Vérifié : `test_draco_session.py` 34 passed, ruff 0 et mypy 0 sur les fichiers touchés | 🟢 corrigé (en attente vérif utilisateur) |
---
+59 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.83.3 | **Dernière mise à jour :** 2026-11-04
> **Version :** 2.86.1 | **Dernière mise à jour :** 2026-11-06
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -30,6 +30,64 @@
## 🔵 En cours
### 227. Hub HUB-22 — API machine des sessions draco (liste et révocation)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.0 (PR #68 fusionnée et release publiée — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/hub-sessions-machine-227.md](./features/hub-sessions-machine-227.md)
- **Description :** vague C du plan Hub v2.1 (HUB-22) : le Hub doit lister
les sessions actives d'un utilisateur et les révoquer une à une ou
toutes, via le canal `jti` livré en HUB-13 (#226). Trois routes sous
la même garde machine (`require_machine_admin`) et la même
journalisation d'audit que les routes comptes existantes :
`GET /api/admin/machine/comptes/{username}/sessions`,
`POST …/sessions/{jti}/revoquer` (jamais de révocation
cross-utilisateur) et `POST …/sessions/revoquer-toutes`
(déconnexion globale, sans désactiver le compte).
- **Sous-tâches :**
- [x] `issued_sessions_for_user` + `revoke_one_for_user` (`backend/draco_session.py`)
- [x] Trois routes sessions (`backend/routers/comptes_machine.py`)
- [x] Tests étendus (`tests/test_comptes_machine.py`, `tests/test_draco_session.py`)
### 228. Hub HUB-19 — propagation des rôles du Hub à ObsiGate (consommatrice)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.1 (voir CHANGELOG ; branche rebasée sur la 2.85.0 avant fusion ; redéploiement Docker requis)
- **Fiche :** [features/hub-roles-obsigate-228.md](./features/hub-roles-obsigate-228.md)
- **Description :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) : le
pilote ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate —
à la fois émettrice et consommatrice du SSO — le consomme à son tour.
À l'ouverture de session draco, consultation de
`GET {hub}/api/roles/obsigate` (Cookie relayé, délai 2 s), cache
mémoire 60 s par `sub` (horodatage posé avant l'appel). Niveau connu
`aucun` → session draco refusée (réponse explicite en français) ;
routes `/api/auth/admin/*` authentifiées par le canal draco :
niveau `administrateur` exigé quand le rôle est connu. Toute erreur
du Hub → rôle inconnu → comportement actuel. L'authentification
native d'ObsiGate (émettrice, accès de secours) n'est jamais touchée ;
validation locale du jeton, émission et révocations inchangées,
aucun rôle dans le jeton.
- **Sous-tâches :**
- [x] Client `backend/hub_roles.py` (contrat D3(a), cache/repli, URL du Hub résolue comme la barre — `draco_config.app_url("hub")`)
- [x] Refus « aucun » dans `draco_session.user_for_draco` + réponse explicite de `require_auth`
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/hub-api-admin-comptes-226.md](./features/hub-api-admin-comptes-226.md)
- **Description :** carte fusion #127 (plan Hub v2 §2.2, D1(a)/D2(a)) : le Hub
administre les comptes dont ObsiGate reste le magasin, via
`/api/admin/machine/comptes` (liste, profil étendu, création,
modification, désactivation/réactivation avec révocation des `jti`
draco, suppression, réinitialisation du mot de passe avec changement
forcé `must_change_password`, réinitialisation du MFA), protégée par
la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` — jamais par une
session. Chaque action est journalisée dans `data/audit.log`.
- **Sous-tâches :**
- [x] Routes machine lecture/écriture + garde par clé dédiée (`backend/routers/comptes_machine.py`)
- [x] Registre des émissions draco + `revoke_all_for_user` (`backend/draco_session.py`)
- [x] Champ `must_change_password` (modèle, connexion, `/me`, changement de mot de passe)
- [x] Journalisation `log_compte_admin` (`backend/audit.py`) + tests `tests/test_comptes_machine.py`
### 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)
@@ -0,0 +1,59 @@
# BUG-122 — Déconnexion globale navigable : `GET /sso/logout`
**Carte :** fusion #141 (signalement de Bruno, 2026-11-06, capture
depuis la console Spark). **Version :** 2.86.0. **Prérequis :** ACC-3.1
(#216) — la déconnexion globale existe déjà dans
`POST /api/auth/logout`.
## Constat
Le bouton « Déconnexion » du menu compte de la barre commune
(dracodev-commons) est une **action relayée à l'hôte** (`cfg.onAction`,
sinon événement `draco:bar`) : la barre ne déconnecte jamais
elle-même. Or :
- console Spark, ObsiGate, Flowdeck, TikTak, ShaarIt et Hub ne
branchaient **aucune** action `deconnexion` (clic sans effet,
reproduit en navigateur réel sur la console : session draco intacte
après le clic) ;
- Kanbante, seul branché, ne détruisait que sa session locale — la
session SSO survivait ;
- et de toute façon, aucun hôte n'aurait pu terminer la session
globale : `POST /api/auth/logout` n'est appelable qu'en same-origin
depuis l'interface d'ObsiGate. Il manquait le point d'entrée
navigable, symétrique de `GET /sso/authorize`.
## Livraison (cette PR, côté émettrice)
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/sso/logout?return_to=<url>` | Déconnexion globale, puis redirection 302 vers le `return_to` validé (repli « / ») |
- La logique de `POST /api/auth/logout` est extraite dans
`apply_global_logout(request, response)` (`backend/auth/router.py`)
et partagée par les deux entrées : révocation des jetons natifs
refresh/access, révocation du `jti` du cookie `draco_session`,
effacement des cookies (dont `draco_session` sur le domaine parent).
- `return_to` passe par la liste blanche existante
(`draco_session.validate_return_to` : https + `dracodev.net` ou
sous-domaine) — aucun relais ouvert.
- Idempotente : sans session, la route redirige quand même (les hôtes
y naviguent même si leur session a déjà expiré).
- La barre d'ObsiGate elle-même branche son action `deconnexion` sur
cette route (`frontend/index.html`, retour à sa racine) — garde :
`tests/test_draco_barre.py::test_barre_deconnexion_branchee`.
## Côté hôtes (PR sœurs, carte fusion #141)
Chaque hôte branche son action `deconnexion` : destruction de sa
session locale d'abord (Flowdeck, TikTak, Kanbante, ShaarIt), puis
navigation vers `<émettrice>/sso/logout?return_to=<sa base>`. La
console et le Hub (session draco seule) naviguent directement.
Après le parcours complet, « Déconnexion » termine **la session de la
suite entière** et la session locale de l'hôte courant.
## Tests
`tests/test_draco_session.py` (+3) : révocation du `jti` + redirection
vers le `return_to` + cookie effacé (`Max-Age=0`), liste blanche
(relais refusé, repli « / »), idempotence sans session.
+105
View File
@@ -0,0 +1,105 @@
# #226 — HUB-13 : API d'administration des comptes (machine-à-machine)
**Carte :** fusion #127 (HUB-13, plan Hub v2 §2.2 et §4 vague A ; décisions
D1(a) — magasin des comptes chez ObsiGate — et D2(a) — ObsiGate émettrice).
**Version :** 2.84.0.
## Objet
Le Hub administre les comptes de la suite depuis son interface
d'administration ; ObsiGate reste le magasin des comptes. Cette API est
le bras armé de cette interface : administration complète des comptes,
en lecture et en écriture, réservée aux appels machine-à-machine.
## Routes — `/api/admin/machine/comptes`
Emplacement choisi : l'espace d'administration existant (`/api/admin/…`),
segment `machine` explicite. Il distingue ces routes de l'administration
par session (`/api/auth/admin/users`, garde `require_admin`) : aucune
session utilisateur, même administratrice, ne les ouvre.
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/api/admin/machine/comptes` | Liste des comptes (profils étendus) |
| GET | `/api/admin/machine/comptes/{username}` | Profil étendu d'un compte |
| POST | `/api/admin/machine/comptes` | Création (201 ; 409 si identifiant pris) |
| PATCH | `/api/admin/machine/comptes/{username}` | Modification (nom affiché, rôle global, langue, vaults) |
| POST | `/api/admin/machine/comptes/{username}/desactiver` | Désactivation + révocation des sessions |
| POST | `/api/admin/machine/comptes/{username}/reactiver` | Réactivation |
| DELETE | `/api/admin/machine/comptes/{username}` | Suppression + révocation des sessions |
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mot-de-passe` | Nouveau mot de passe temporaire, changement forcé |
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mfa` | Effacement de l'enrôlement TOTP |
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
configurée), 409 conflit d'identifiant, 422 corps invalide.
## Protection
- Clé machine **dédiée** : `DRACO_ADMIN_MACHINE_KEY`, en-tête
`X-Draco-Machine-Key` (même famille et même en-tête que
`GET /api/sso/revocations`). Distincte de `DRACO_SSO_MACHINE_KEY` :
l'écriture de comptes est plus sensible que la lecture des
révocations (plan §3). Comparaison à temps constant.
- Sans la variable, l'API est inerte (404 « Non configuré »).
- Acteur consigné : en-tête facultatif `X-Draco-Actor` (défaut
`service-machine`).
## Profil étendu
Liste blanche stricte : `id`, `username`, `display_name`, `language`,
`avatar` (valeur du modèle — data-URL ou `null`, comme
`GET /api/auth/me`) et `avatar_present`, `role`, `active`, `vaults`,
`mfa_enabled` (oui/non), `mfa_method`, `must_change_password`,
`created_at`, `last_login`. **Jamais** le haché du mot de passe, le
secret TOTP, les codes de récupération ni les identifiants WebAuthn.
## Désactivation et sessions
- `active = false` → la connexion est refusée (401 uniforme, BUG-039)
et plus aucun jeton n'est émis.
- Les `jti` draco émis pour le compte sont révoqués via le mécanisme
existant (`draco_session.revoke`, liste publiée par
`GET /api/sso/revocations`). Pour les connaître, `draco_session`
tient désormais un registre des émissions (`data/draco_issued.json`,
`jti → exp` par `sub`, purgé à l'expiration) et
`revoke_all_for_user(sub)`.
- Le repère `password_changed_at` (BUG-028) est avancé : les jetons
natifs déjà émis sont invalidés et ne ressuscitent pas à la
réactivation. Les anciennes sessions draco restent révoquées après
réactivation : une nouvelle connexion est nécessaire.
## Changement forcé du mot de passe
Nouveau champ du modèle utilisateur : `must_change_password` (booléen,
défaut `false` — ajouté par `create_user`, aucun fichier à migrer :
les comptes existants sans le champ valent `false`). Posé à la
création machine et à la réinitialisation du mot de passe ; signalé
dans la réponse de connexion et `GET /api/auth/me` ; effacé par
`POST /api/auth/change-password`.
## Réinitialisation du MFA
Efface `mfa_secret`, `mfa_secret_pending` et `mfa_recovery_codes`.
Les clés WebAuthn éventuelles sont conservées et gardent le MFA
actif (méthode `webauthn`) ; sans elles, `mfa_enabled` passe à `false`.
## Journalisation
Chaque action est consignée dans le journal d'audit existant
(`data/audit.log`, JSON lignes) via `backend.audit.log_compte_admin` :
horodatage, `acteur` (identité de service), `action` (`compte_cree`,
`compte_modifie`, `compte_desactive`, `compte_reactive`,
`compte_supprime`, `mot_de_passe_reinitialise`, `mfa_reinitialise`),
`cible` (compte visé), IP et détails non sensibles (champs modifiés,
nombre de `jti` révoqués — jamais de mot de passe ni de secret).
## Tests
`tests/test_comptes_machine.py` : refus sans clé / mauvaise clé / clé
SSO / session admin, API inerte non configurée, liste et profil étendu
sans secret, création puis lecture et connexion, doublon 409,
modification, désactivation (connexion refusée, session native
refusée, `jti` draco révoqué et publié) puis réactivation,
réinitialisation du mot de passe (ancien refusé, nouveau accepté,
changement forcé signalé puis effacé au changement), réinitialisation
MFA (connexion directe après), suppression, journalisation.
+77
View File
@@ -0,0 +1,77 @@
# #228 — HUB-19 : propagation des rôles du Hub à ObsiGate
**Carte :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) — le pilote
ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate le consomme à
son tour, comme hôte à la fois **émettrice** et **consommatrice** du SSO.
**Version cible :** 2.85.1 (branche depuis le main 2.84.1 ; hypothèse : à
rebaser après la PR « API sessions HUB-22 » — 2.85.0 — si elle fusionne
d'abord).
## Contrat consommé (D3(a))
À l'ouverture de session draco — chez ObsiGate : la validation locale du
cookie partagé, `draco_session.user_for_draco()` (ACC-3.2, #217), appelée
par le repli draco de `get_current_user` — consultation du registre :
- `GET {hub}/api/roles/obsigate`, en-tête **Cookie complet** de la requête
relayé tel quel, `Accept: application/json`, délai **2 s** ;
- réponse 200 : `{"app", "sub", "role", "explicite", "ttl_secondes": 60}`,
`role` ∈ {`aucun`, `utilisateur`, `administrateur`} ;
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de la
barre commune (#223) — `DRACO_HUB_URL` explicite d'abord, dérivation
par l'hôte sœur (suffixe d'environnement de l'hôte de l'agent)
ensuite, défaut officiel enfin. Jamais d'URL dev figée.
## Client — `backend/hub_roles.py`
Sur le patron du pilote (`src/hub_roles.rs` de ShaarIt) :
- cache mémoire par `sub`, TTL **60 s** ;
- horodatage posé **avant** l'appel : un échec conserve l'ancien cache
et ne déclenche pas de rafale vers un Hub en panne ;
- toute erreur (réseau, délai, statut ≠ 200, JSON illisible, rôle hors
contrat) → rôle **inconnu** (`None`) → l'appelant garde son
comportement actuel. Le registre ne crée jamais un verrouillage
nouveau.
## Effets (sessions établies par draco seulement)
1. **Niveau connu `aucun`** → `user_for_draco` refuse : pas de session
établie par draco. `require_auth` répond alors **403** avec un
message explicite en français (niveau « aucun », matrice des rôles
du Hub, accès natif toujours possible), consigné via
`request.state.role_hub_refuse`. L'**authentification native**
d'ObsiGate (son propre login — c'est l'émettrice, son accès propre
est le secours) n'est **jamais** touchée.
2. **Routes d'administration `/api/auth/admin/*`** (CRUD des comptes,
`backend/auth/router.py`) : nouvelle garde `require_admin_hub`
(`backend/auth/middleware.py`) = `require_admin` + , quand la
requête est authentifiée par le **canal draco** (`_draco_sub`
présent, `_hub_role` joint par `user_for_draco`) et que le rôle est
**connu**, exigence du niveau `administrateur` du registre.
Sessions natives → règles actuelles inchangées ; rôle inconnu →
règles actuelles inchangées. Le registre n'élève aucun privilège :
un compte local non admin reste refusé par la règle locale, quel
que soit son niveau au Hub.
3. **Inchangés** : validation locale du jeton, émission du cookie,
révocations (`jti`), renouvellement glissant. **Aucun rôle dans le
jeton** (claims minimaux de #216 préservés).
Périmètre assumé : la garde du registre vise les routes nommées par la
carte, `/api/auth/admin/*`. Les autres surfaces gardées par
`require_admin` (configuration, vaults, coffres…) gardent leurs règles
actuelles ; les y soumettre serait un changement d'une ligne par
surface (`require_admin_hub` est réutilisable tel quel) si Bruno le
décide.
## Tests
`tests/test_hub_roles.py` : parsing du contrat (URL, Cookie relayé,
`Accept`, délai 2 s), rôles reconnus / hors contrat, JSON illisible,
erreurs HTTP et réseau → inconnu ; résolution de l'URL du Hub
(explicite, dérivée `-dev`, défaut officiel) ; cache par `sub`, TTL,
échec sans rafale avec ancien cache conservé ; intégration sur le
chemin draco (refus « aucun » explicite, login natif intact sous
« aucun », rôle inconnu = comportement actuel, `utilisateur` Hub
refusé sur `/api/auth/admin/*`, `administrateur` Hub accepté, compte
local non admin non élevé, session native jamais bloquée par le Hub).
+85
View File
@@ -0,0 +1,85 @@
# #227 — HUB-22 : API machine des sessions draco (liste et révocation)
**Carte :** vague C du plan Hub v2.1 (HUB-22) — le Hub consomme ces
routes en 0.17.0 pour l'écran des sessions d'un utilisateur.
**Version :** 2.85.0. **Prérequis :** HUB-13 (#226) — registre des
émissions `jti` et garde machine.
## Objet
Le Hub doit lister les sessions actives d'un utilisateur et les
révoquer une à une ou toutes, via le canal `jti` livré en HUB-13.
Aucune route machine « sessions » n'existait : cette livraison la crée,
sous la même garde (`require_machine_admin`, clé
`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` /
`X-Draco-Actor`) et avec la même journalisation d'audit que les routes
comptes existantes. Sans la clé configurée, l'API reste inerte (404) ;
sans clé valide, 401.
## Routes — `/api/admin/machine/comptes/{username}/sessions`
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/api/admin/machine/comptes/{username}/sessions` | Sessions actives du compte |
| POST | `/api/admin/machine/comptes/{username}/sessions/{jti}/revoquer` | Révocation d'une session |
| POST | `/api/admin/machine/comptes/{username}/sessions/revoquer-toutes` | Révocation de toutes les sessions |
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
configurée), 404 `jti` absent de l'ensemble actif du compte.
### Liste — `GET …/sessions`
`200 {"username": …, "sessions": [{"jti", "exp", "expire_le",
"revoquee"}]}` — une entrée par `jti` émis et non expiré, triée par
`exp` croissant ; `expire_le` est l'échéance en ISO 8601 UTC et
`revoquee` signale un `jti` déjà présent dans la liste des
révocations.
### Révocation unitaire — `POST …/sessions/{jti}/revoquer`
`200 {"jti", "revoquee": true}`. Le `jti` n'est révoqué que s'il
appartient à l'ensemble émis du compte visé et n'est pas expiré :
**404 sinon, jamais de révocation cross-utilisateur**.
### Tout révoquer — `POST …/sessions/revoquer-toutes`
`200 {"revoquees": n}` — réutilise `revoke_all_for_user` de HUB-13.
C'est la « déconnexion globale », **distincte de la désactivation** :
le compte reste actif, une nouvelle connexion émet une nouvelle
session.
## Backend — `backend/draco_session.py`
- `issued_sessions_for_user(sub)` : sessions actives
(`exp > maintenant`) du `sub`, `[{jti, exp, revoquee}]` triées par
`exp` croissant ; les entrées expirées du `sub` sont purgées du
registre au passage.
- `revoke_one_for_user(sub, jti)` : révoque via le `revoke()`
existant uniquement si le `jti` appartient à l'ensemble émis de ce
`sub` et n'est pas expiré ; retourne un booléen.
La clé du registre est le même `sub` que celui de HUB-13
(`_identifiant_sessions` dans `comptes_machine.py` : `id` du compte,
à défaut `username`) — factorisée depuis `_revoquer_sessions`, qui
l'utilise aussi désormais.
## Journalisation
Les deux écritures sont consignées dans `data/audit.log` via
`log_compte_admin` : actions `session_revoquee` (détail : le `jti`) et
`sessions_revoquees` (détail : nombre de `jti` révoqués), acteur =
identité de service `X-Draco-Actor`. La lecture suit le patron des
lectures existantes (non journalisée).
## Tests
- `tests/test_draco_session.py` : liste triée et marquage `revoquee`,
purge des expirés, révocation unitaire, refus cross-utilisateur,
refus d'un `jti` expiré ; le registre des émissions est isolé en
fichier temporaire dans la fixture.
- `tests/test_comptes_machine.py` (`TestSessions`) : liste (contenu,
`expire_le` ISO 8601 UTC), liste vide, compte inconnu 404,
révocation unitaire (succès + effet sur `verify_token`),
cross-utilisateur 404 sans effet, tout-révoquer (compte toujours
actif, autres comptes intacts, seconde passe à 0), garde (401 sans
clé, 404 API non configurée), journalisation.
+24
View File
@@ -1214,6 +1214,30 @@
function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
// Thème commun de la suite (HUB-18, fusion #132) : la barre
// commune (barre v0.5.0) applique la préférence du Hub
// (sombre/clair/système) à l'ouverture — directement sur
// data-theme, et via l'événement « draco:theme » pour que le
// mécanisme propre d'ObsiGate suive (icône et libellé du bouton,
// feuilles highlight.js). applyTheme mémorise le mode appliqué
// dans « obsigate-theme » : la préférence du Hub devient le
// choix local courant, et le bouton de bascule reste le maître
// pour la suite de la session (aucune ré-application de la barre
// après l'ouverture). « systeme » arrive déjà résolu en mode
// effectif (detail.mode) par la barre. Repli : si le Hub ne
// répond pas, aucun événement n'arrive et le choix local
// ci-dessus reste seul maître, comme avant.
document.addEventListener("draco:theme", (e) => {
const mode = e && e.detail && e.detail.mode;
if (mode === "dark" || mode === "light") applyTheme(mode);
});
// Si la barre s'est initialisée AVANT ce module (ordre des
// scripts defer/module selon la page), sa préférence déjà
// résolue depuis son cache est reprise ici.
const barre = window.DRACO_BAR;
if (barre && (barre.themeMode === "dark" || barre.themeMode === "light")) {
applyTheme(barre.themeMode);
}
}
function applyTheme(theme) {
+22 -1
View File
@@ -24,7 +24,14 @@
compte (Télécharger, À Propos, Aides, Profil) réutilisent
.draco-menu-item (déjà ancre-compatible : text-decoration none)
et le séparateur .draco-menu-sep du menu des applications.
v0.4.0 (HUB-11) : .draco-menu-icone — les icônes monoline des
items du menu compte (SVG inline construit par le JS) : 18 px,
replacées à gauche du libellé par order: -1 (l'item est en
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
Jetons repris du prototype validé Console v7.2.
v0.4.1 (fusion #125) : menu compte resserré — min-width
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
le menu était trop large une fois les icônes visibles).
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien
(aucun attribut data-theme sur <html>). */
@@ -82,7 +89,7 @@
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
min-width: 232px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
@@ -94,6 +101,20 @@
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
/* Item conditionnel masqué (v0.6.0 : « Administration » tant que
l'hôte n'a pas affirmé le statut d'administrateur) — le
display:flex ci-dessus écraserait l'attribut hidden sans cette
règle, même patron que .draco-compte-menu[hidden]. */
.draco-menu-item[hidden] { display: none; }
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
libellé dans le DOM, replacées à gauche par order: -1. */
.draco-menu-icone {
order: -1; flex: none; display: inline-flex; align-items: center;
width: 18px; height: 18px; color: var(--ink-2);
}
.draco-menu-icone svg { display: block; width: 18px; height: 18px; }
.draco-menu-item:hover .draco-menu-icone,
.draco-menu-item:focus-visible .draco-menu-icone { color: var(--ink); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
+277
View File
@@ -29,6 +29,35 @@
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
* sans étape « href rendu » (voir « URL » ci-dessous).
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
* reçoit un glyphe SVG inline dans le style des icônes du rail de
* la console (contour, trait fin régulier, currentColor, aucun
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
* comportement : liens résolus et Déconnexion relayée inchangés.
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
* la barre apprend la préférence de thème du profil du Hub
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
* local court et repli intégral sur le comportement actuel de
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
* v0.6.0 (demande de Bruno, 2026-11-06) : le menu compte gagne un
* cinquième lien Hub, « Administration » → <hub>/admin (le Hub y
* redirige vers /admin/utilisateurs), placé après le séparateur,
* avant « Déconnexion », avec son icône monoline (curseurs de
* réglage). AFFICHAGE CONDITIONNEL : l'entrée n'apparaît que si
* l'hôte AFFIRME que le compte courant est administrateur de
* suite — cfg.admin === true à la configuration, ou
* DRACO_BAR.setAdmin(true) quand l'hôte l'apprend après coup
* (patron setUrls). L'hôte ne passe le drapeau que s'il l'a établi
* depuis SA source d'autorité (rôle global du compte chez
* ObsiGate, niveau du registre des rôles du Hub pour son app…) ;
* à défaut de certitude, pas de drapeau et l'entrée reste masquée.
* La barre ne devine jamais : le jeton draco ne porte aucun rôle
* (claims minimaux, par design). La vraie protection reste la
* garde serveur du Hub (/admin/*), pas ce masquage d'affichage.
* Voir le README, « Administration » — contrat de visibilité.
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -41,6 +70,11 @@
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* admin: true, // (v0.6.0, optionnel)
* // administrateur de suite ÉTABLI par l'hôte : démasque
* // l'entrée « Administration » du menu compte. Absent ou
* // non-strictement-true → entrée masquée. Peut aussi être
* // posé après coup via DRACO_BAR.setAdmin(true).
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
@@ -261,6 +295,131 @@
}
var resolvedUrls = resolveUrls();
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
* La préférence de thème de la suite vit dans le profil du Hub :
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
* authentifié par le cookie draco (credentials: "include").
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
* défaut officiel) — jamais un nom figé de plus.
* Application par le mécanisme data-theme existant des hôtes :
* "sombre" → data-theme="dark", "clair" → data-theme="light",
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
* html:not([data-theme]) du CSS de la barre comme des hôtes).
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
* propre doit suivre (icônes et libellés, feuilles de style,
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
* s'initialise après la barre lit la même information dans
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
* Ordre d'application : 1) le cache local FRAIS est appliqué de
* façon SYNCHRONE à l'initialisation de la barre (script en defer
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
* a changé. À la toute première visite (aucun cache), un bref
* flash du thème local de l'hôte reste possible le temps du fetch :
* c'est le prix du repli — l'état affiché transitoire est le thème
* que l'hôte aurait de toute façon choisi seul, jamais un état
* cassé, et les visites suivantes sont servies par le cache.
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
* jamais le choix local de l'hôte.
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
* existant. Le comportement actuel de l'hôte (son localStorage,
* puis le système) reste alors exactement le maître, comme avant
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
* fetch) ; elle ne ré-applique ensuite que sur changement du
* schéma système, et seulement tant que la préférence résolue est
* "systeme". */
var THEME_CACHE_CLE = "draco-theme-hub";
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
function themeSystemeEstSombre() {
try {
return !!(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) { return false; }
}
function lireCacheTheme() {
try {
var brut = window.localStorage &&
window.localStorage.getItem(THEME_CACHE_CLE);
if (!brut) return null;
var c = JSON.parse(brut);
if (!c || typeof c.ts !== "number") return null;
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
return c.theme;
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
return null;
}
function ecrireCacheTheme(theme) {
try {
if (window.localStorage) {
window.localStorage.setItem(THEME_CACHE_CLE,
JSON.stringify({ theme: theme, ts: Date.now() }));
}
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
}
function appliquerThemeCommun(theme) {
var root = document.documentElement;
if (!root) return;
if (theme === "sombre" || theme === "clair") {
themeMode = theme === "sombre" ? "dark" : "light";
root.setAttribute("data-theme", themeMode);
} else if (theme === "systeme") {
themeMode = themeSystemeEstSombre() ? "dark" : "light";
root.removeAttribute("data-theme");
} else {
return;
}
themePref = theme;
if (window.DRACO_BAR) {
window.DRACO_BAR.themePref = themePref;
window.DRACO_BAR.themeMode = themeMode;
}
try {
document.dispatchEvent(new CustomEvent("draco:theme",
{ detail: { theme: themePref, mode: themeMode } }));
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
}
function initThemeCommun() {
var enCache = lireCacheTheme();
if (enCache) appliquerThemeCommun(enCache);
/* Suivi du schéma système tant que la préférence est "systeme" :
* sans data-theme, le CSS suit tout seul ; l'événement est
* re-dispatché pour les hôtes qui matérialisent le mode. */
try {
var mq = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)");
if (mq && mq.addEventListener) {
mq.addEventListener("change", function () {
if (themePref === "systeme") appliquerThemeCommun("systeme");
});
}
} catch (e) { /* pas de matchMedia : pas de suivi */ }
if (typeof window.fetch !== "function") return;
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
"api/profil/theme";
window.fetch(url, { credentials: "include" }).then(function (resp) {
if (!resp || !resp.ok) return null;
return resp.json();
}).then(function (data) {
if (!data) return;
var t = data.theme;
if (t === "sombre" || t === "clair" || t === "systeme") {
ecrireCacheTheme(t);
appliquerThemeCommun(t);
}
/* theme null ou inconnu : repli intégral, rien n'est touché. */
}).catch(function () { /* Hub injoignable : repli intégral */ });
}
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -483,6 +642,93 @@
if (!appsMenu.hidden) lastOpener = brand;
}
/* Icônes monoline du menu compte (v0.4.0) — glyphes contour
* dans le style des icônes du rail de la console : viewBox 24,
* stroke currentColor, remplissage nul, extrémités rondes.
* Chaque glyphe est une liste de primitives [balise, attributs]
* construites en DOM (jamais d'innerHTML), comme le reste du
* fichier. Le libellé reste le nœud texte de l'item ; l'icône,
* appendue après, est replacée à gauche par le CSS (order: -1
* sur .draco-menu-icone, l'item étant déjà en flex). */
var MENU_ICONES = {
/* Flèche vers le bas dans un plateau. */
"telecharger": [
["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
["polyline", { points: "7 10 12 15 17 10" }],
["line", { x1: "12", y1: "15", x2: "12", y2: "3" }]
],
/* Cercle d'information. */
"a-propos": [
["circle", { cx: "12", cy: "12", r: "9" }],
["line", { x1: "12", y1: "11", x2: "12", y2: "16" }],
["line", { x1: "12", y1: "8", x2: "12.01", y2: "8" }]
],
/* Cercle d'interrogation. */
"aide": [
["circle", { cx: "12", cy: "12", r: "9" }],
["path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
["line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }]
],
/* Personne : tête + épaules. */
"profil": [
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
["circle", { cx: "12", cy: "7", r: "4" }]
],
/* Curseurs de réglage (trois axes, trois boutons) — v0.6.0. */
"admin": [
["line", { x1: "4", y1: "21", x2: "4", y2: "14" }],
["line", { x1: "4", y1: "10", x2: "4", y2: "3" }],
["line", { x1: "12", y1: "21", x2: "12", y2: "12" }],
["line", { x1: "12", y1: "8", x2: "12", y2: "3" }],
["line", { x1: "20", y1: "21", x2: "20", y2: "16" }],
["line", { x1: "20", y1: "12", x2: "20", y2: "3" }],
["line", { x1: "1", y1: "14", x2: "7", y2: "14" }],
["line", { x1: "9", y1: "8", x2: "15", y2: "8" }],
["line", { x1: "17", y1: "16", x2: "23", y2: "16" }]
],
/* Flèche sortant d'un cadre (porte). */
"deconnexion": [
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
["polyline", { points: "16 17 21 12 16 7" }],
["line", { x1: "21", y1: "12", x2: "9", y2: "12" }]
]
};
/* Espace de noms SVG — OBLIGATOIRE : un élément « svg » (ou
* « path », « circle »…) créé par createElement() appartient à
* l'espace de noms HTML et le navigateur ne le dessine JAMAIS
* (bug v0.4.0 : libellés décalés d'un cran d'icône, aucun glyphe
* visible ; corrigé en v0.4.1, carte fusion #125). */
var SVG_NS = "http://www.w3.org/2000/svg";
function menuIcone(nom) {
var span = document.createElement("span");
span.className = "draco-menu-icone";
span.setAttribute("aria-hidden", "true");
var svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("focusable", "false");
(MENU_ICONES[nom] || []).forEach(function (spec) {
var el = document.createElementNS(SVG_NS, spec[0]);
for (var k in spec[1]) el.setAttribute(k, spec[1][k]);
svg.appendChild(el);
});
span.appendChild(svg);
return span;
}
/* Visibilité de l'entrée « Administration » (v0.6.0) : vraie
* seulement si l'hôte l'a AFFIRMÉE — cfg.admin === true à la
* configuration, ou DRACO_BAR.setAdmin(true) après coup. La
* barre ne déduit jamais ce statut (le jeton draco ne porte
* aucun rôle) ; à défaut, l'entrée reste masquée et la garde
* serveur du Hub reste la vraie protection. */
var compteAdmin = cfg.admin === true;
var adminItem = null;
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
* d'identité, puis les liens du Hub (Télécharger, À Propos,
* Aides, Profil — vrais <a href>, même patron que le menu des
@@ -508,18 +754,36 @@
a.setAttribute("data-hub-link", pair[0]);
a.setAttribute("role", "menuitem");
a.textContent = pair[1];
a.appendChild(menuIcone(pair[0]));
compteMenu.appendChild(a);
});
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
compteMenu.appendChild(sep);
/* « Administration » (v0.6.0) : cinquième lien Hub
* (<hub>/admin, où le Hub redirige vers /admin/utilisateurs).
* data-hub-link="admin" : son href est resynchronisé par
* refreshCompteHrefs / setUrls comme les quatre autres liens.
* Toujours construit ; masqué tant que l'hôte n'a pas affirmé
* le statut d'administrateur (compteAdmin). */
var adm = document.createElement("a");
adm.className = "draco-menu-item";
adm.href = hubHref("admin");
adm.setAttribute("data-hub-link", "admin");
adm.setAttribute("role", "menuitem");
adm.textContent = "Administration";
adm.appendChild(menuIcone("admin"));
adm.hidden = !compteAdmin;
compteMenu.appendChild(adm);
adminItem = adm;
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", "deconnexion");
b.setAttribute("role", "menuitem");
b.textContent = "Déconnexion";
b.appendChild(menuIcone("deconnexion"));
compteMenu.appendChild(b);
bar.appendChild(compteMenu);
}
@@ -528,6 +792,7 @@
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
if (adminItem) adminItem.hidden = !compteAdmin;
refreshCompteHrefs();
}
function toggleCompteMenu(force) {
@@ -620,12 +885,23 @@
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
themePref: null, themeMode: null,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
refreshCompteHrefs();
},
/* Administration (v0.6.0) : l'hôte pose le drapeau quand il a
* ÉTABLI que le compte courant est administrateur de suite
* (sa source d'autorité — voir l'en-tête et le README). Toute
* valeur autre que le booléen true masque l'entrée. */
setAdmin: function (v) {
compteAdmin = v === true;
if (adminItem) adminItem.hidden = !compteAdmin;
}
};
@@ -634,6 +910,7 @@
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
initThemeCommun();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+12
View File
@@ -410,6 +410,7 @@
return;
}
if (act === "palette") { location.href = "/recherche"; return; }
if (act === "deconnexion") { location.href = "/sso/logout?return_to=" + encodeURIComponent(location.origin + "/"); return; }
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
if (act === "spark") {
@@ -438,6 +439,17 @@
var av = document.getElementById("draco-avatar");
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
}
/* Barre v0.6.0 (commons) : entrée « Administration »
du menu compte. Le rôle global du compte chez
ObsiGate EST la définition de l'administrateur
de suite (c'est lui que la garde /admin/* du Hub
vérifie, via ce même /api/auth/me) — ObsiGate le
connaît nativement, il l'affirme à la barre.
Tout autre rôle (ou échec) : pas d'affirmation,
l'entrée reste masquée. */
if (u && window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
window.DRACO_BAR.setAdmin(u.role === "admin");
}
}).catch(function () { /* avatar générique conservé */ });
});
</script>
+19
View File
@@ -386,6 +386,12 @@ const AuthManager = {
const section = document.getElementById("sidebar-user");
if (!user || !this._authEnabled) {
if (section) section.hidden = true;
// Barre commune v0.6.0 (fusion #142) : sans identité
// établie, l'entrée « Administration » du menu compte
// reste masquée.
if (window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
window.DRACO_BAR.setAdmin(false);
}
return;
}
if (!section) return;
@@ -427,6 +433,19 @@ const AuthManager = {
});
}
// Barre commune v0.6.0 (fusion #142) : l'entrée
// « Administration » du menu compte suit le rôle natif du
// compte — la définition même de l'administrateur de suite
// (garde /admin/* du Hub), même source que la rangée
// d'administration native ci-dessous. renderUserSection()
// est appelée par showApp() pour toutes les sessions
// établies (connexion, restauration, renouvellement
// silencieux) ; le fetch d'amorçage d'index.html ne couvre
// que les sessions déjà présentes au chargement de la page.
if (window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
window.DRACO_BAR.setAdmin(user.role === "admin");
}
const adminRow = document.getElementById("admin-menu-row");
if (adminRow) {
if (user.role === "admin") {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.83.3",
"version": "2.86.1",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+534
View File
@@ -0,0 +1,534 @@
# tests/test_comptes_machine.py — HUB-13 (fusion #127)
# API d'administration des comptes machine-à-machine
# (/api/admin/machine/comptes), protégée par la clé dédiée
# DRACO_ADMIN_MACHINE_KEY : lecture/écriture, désactivation avec
# révocation des sessions, réinitialisations du mot de passe (avec
# changement forcé) et du MFA, suppression, journalisation d'audit.
import json
import os
import shutil
import tempfile
from pathlib import Path
import pytest
MACHINE_KEY = "cle-admin-machine-test"
MACHINE_HEADERS = {"X-Draco-Machine-Key": MACHINE_KEY,
"X-Draco-Actor": "hub-test"}
BASE = "/api/admin/machine/comptes"
@pytest.fixture
def machine_client():
"""TestClient avec auth active, données isolées, clés machine posées."""
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
from backend.auth.mfa import generate_secret
from backend.auth.password import hash_password
users = {
"version": 1,
"users": {
"admin": {
"id": "admin-1",
"username": "admin",
"display_name": "admin",
"password_hash": hash_password("chab30"),
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
"mfauser": {
"id": "user-mfa",
"username": "mfauser",
"display_name": "Utilisateur MFA",
"password_hash": hash_password("motdepasse-mfa"),
"role": "user",
"vaults": ["TestVault"],
"active": True,
"created_at": "2026-01-01T00:00:00",
"mfa_enabled": True,
"mfa_secret": generate_secret(),
"mfa_method": "totp",
"mfa_recovery_codes": ["hash-fictif"],
},
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
os.environ["DRACO_ADMIN_MACHINE_KEY"] = MACHINE_KEY
os.environ["DRACO_SSO_MACHINE_KEY"] = "cle-sso-test"
os.environ["DRACO_SESSION_SECRET"] = "secret-draco-test"
from backend import draco_session
draco_session._revoked = None
draco_session._issued = None
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
import asyncio
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app)
yield client
if hasattr(client, "close"):
client.close()
loop.run_until_complete(asyncio.sleep(0))
draco_session._revoked = None
draco_session._issued = None
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_WATCHER_ENABLED", "DRACO_ADMIN_MACHINE_KEY",
"DRACO_SSO_MACHINE_KEY", "DRACO_SESSION_SECRET"]:
os.environ.pop(k, None)
def _login(client, username, password):
return client.post("/api/auth/login",
json={"username": username, "password": password})
def _creer(client, username="nouveau", password="motdepasse-initial", **kw):
corps = {"username": username, "password": password,
"display_name": kw.pop("display_name", "Nouveau Compte"),
"role": kw.pop("role", "user")}
corps.update(kw)
return client.post(BASE, headers=MACHINE_HEADERS, json=corps)
# ── Protection par clé machine ──────────────────────────────────────────
class TestProtection:
def test_sans_cle_refuse(self, machine_client):
assert machine_client.get(BASE).status_code == 401
assert machine_client.post(
BASE, json={"username": "x1", "password": "motdepasse"}
).status_code == 401
def test_mauvaise_cle_refusee(self, machine_client):
resp = machine_client.get(
BASE, headers={"X-Draco-Machine-Key": "mauvaise-cle"})
assert resp.status_code == 401
def test_cle_sso_ne_suffit_pas(self, machine_client):
"""La clé des révocations (lecture) n'ouvre pas l'administration."""
resp = machine_client.get(
BASE, headers={"X-Draco-Machine-Key": "cle-sso-test"})
assert resp.status_code == 401
def test_session_admin_ne_suffit_pas(self, machine_client):
"""Même une session administratrice ordinaire est refusée."""
token = _login(machine_client, "admin", "chab30").json()["access_token"]
resp = machine_client.get(
BASE, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
def test_non_configuree_inerte(self, machine_client, monkeypatch):
monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY")
resp = machine_client.get(BASE, headers=MACHINE_HEADERS)
assert resp.status_code == 404
# ── Lecture ─────────────────────────────────────────────────────────────
class TestLecture:
def test_liste(self, machine_client):
resp = machine_client.get(BASE, headers=MACHINE_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 2
noms = [c["username"] for c in data["comptes"]]
assert "admin" in noms and "mfauser" in noms
def test_profil_etendu_sans_secret(self, machine_client):
resp = machine_client.get(f"{BASE}/mfauser", headers=MACHINE_HEADERS)
assert resp.status_code == 200
profil = resp.json()
assert profil["username"] == "mfauser"
assert profil["display_name"] == "Utilisateur MFA"
assert profil["language"] == "fr"
assert profil["avatar_present"] is False
assert profil["role"] == "user"
assert profil["active"] is True
assert profil["mfa_enabled"] is True
# Jamais de secret : ni le haché du mot de passe, ni le TOTP.
brut = resp.text
assert "password_hash" not in brut
assert "mfa_secret" not in brut
assert "mfa_recovery" not in brut
def test_compte_inconnu_404(self, machine_client):
resp = machine_client.get(f"{BASE}/personne", headers=MACHINE_HEADERS)
assert resp.status_code == 404
# ── Création / modification ─────────────────────────────────────────────
class TestCreationModification:
def test_creation_puis_lecture(self, machine_client):
resp = _creer(machine_client)
assert resp.status_code == 201, resp.text
profil = resp.json()
assert profil["username"] == "nouveau"
assert profil["must_change_password"] is True
lu = machine_client.get(f"{BASE}/nouveau", headers=MACHINE_HEADERS)
assert lu.status_code == 200
assert lu.json()["display_name"] == "Nouveau Compte"
# Le compte créé se connecte, avec changement forcé signalé.
login = _login(machine_client, "nouveau", "motdepasse-initial")
assert login.status_code == 200
assert login.json()["user"]["must_change_password"] is True
def test_creation_doublon_409(self, machine_client):
assert _creer(machine_client).status_code == 201
assert _creer(machine_client).status_code == 409
assert _creer(machine_client, username="admin").status_code == 409
def test_modification(self, machine_client):
assert _creer(machine_client).status_code == 201
resp = machine_client.patch(
f"{BASE}/nouveau", headers=MACHINE_HEADERS,
json={"display_name": "Nom Modifié", "role": "admin"})
assert resp.status_code == 200
assert resp.json()["display_name"] == "Nom Modifié"
assert resp.json()["role"] == "admin"
def test_modification_inconnue_404(self, machine_client):
resp = machine_client.patch(
f"{BASE}/personne", headers=MACHINE_HEADERS,
json={"display_name": "X"})
assert resp.status_code == 404
# ── Désactivation / réactivation ────────────────────────────────────────
class TestDesactivation:
def test_desactivation_revoque_puis_reactivation(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
login = _login(machine_client, "nouveau", "motdepasse-initial")
assert login.status_code == 200
token_natif = login.json()["access_token"]
profil = machine_client.get(
f"{BASE}/nouveau", headers=MACHINE_HEADERS).json()
token_draco, jti, _exp = draco_session.issue_token(
{"id": profil["id"], "username": "nouveau",
"display_name": "Nouveau Compte"})
assert draco_session.verify_token(token_draco) is not None
resp = machine_client.post(f"{BASE}/nouveau/desactiver",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json()["active"] is False
# Connexion refusée, ancienne session native refusée,
# jti draco révoqué et publié dans la liste des révocations.
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 401
me = machine_client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {token_natif}"})
assert me.status_code == 401
assert draco_session.verify_token(token_draco) is None
revocations = machine_client.get(
"/api/sso/revocations",
headers={"X-Draco-Machine-Key": "cle-sso-test"})
assert revocations.status_code == 200
assert jti in [r["jti"] for r in revocations.json()["revoked"]]
resp = machine_client.post(f"{BASE}/nouveau/reactiver",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json()["active"] is True
# Réactivation : nouvelle connexion OK, ancien jti toujours révoqué.
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 200
assert draco_session.verify_token(token_draco) is None
# ── Réinitialisation du mot de passe ────────────────────────────────────
class TestReinitialisationMotDePasse:
def test_ancien_refuse_nouveau_accepte_changement_force(
self, machine_client):
assert _creer(machine_client).status_code == 201
resp = machine_client.post(
f"{BASE}/nouveau/reinitialiser-mot-de-passe",
headers=MACHINE_HEADERS,
json={"password": "nouveau-mot-de-passe"})
assert resp.status_code == 200
assert resp.json()["must_change_password"] is True
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 401
login = _login(machine_client, "nouveau", "nouveau-mot-de-passe")
assert login.status_code == 200
assert login.json()["user"]["must_change_password"] is True
# Le changement effectif par l'utilisateur efface le marqueur.
token = login.json()["access_token"]
changement = machine_client.post(
"/api/auth/change-password",
headers={"Authorization": f"Bearer {token}"},
json={"current_password": "nouveau-mot-de-passe",
"new_password": "mot-de-passe-choisi"})
assert changement.status_code == 200, changement.text
login2 = _login(machine_client, "nouveau", "mot-de-passe-choisi")
assert login2.status_code == 200
assert login2.json()["user"]["must_change_password"] is False
# ── Réinitialisation du MFA ─────────────────────────────────────────────
class TestReinitialisationMfa:
def test_mfa_reinitialise(self, machine_client):
# Avant : la connexion exige le second facteur.
login = _login(machine_client, "mfauser", "motdepasse-mfa")
assert login.status_code == 200
assert login.json().get("mfa_required") is True
resp = machine_client.post(f"{BASE}/mfauser/reinitialiser-mfa",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json()["mfa_enabled"] is False
# Après : connexion directe, sans second facteur.
login = _login(machine_client, "mfauser", "motdepasse-mfa")
assert login.status_code == 200
assert "access_token" in login.json()
# ── Suppression ─────────────────────────────────────────────────────────
class TestSuppression:
def test_suppression(self, machine_client):
assert _creer(machine_client).status_code == 201
resp = machine_client.delete(f"{BASE}/nouveau",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert machine_client.get(
f"{BASE}/nouveau", headers=MACHINE_HEADERS).status_code == 404
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 401
def test_suppression_inconnue_404(self, machine_client):
resp = machine_client.delete(f"{BASE}/personne",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
def _emettre(client, username, nombre=1):
"""Émet ``nombre`` jetons draco pour un compte ; retourne
[(jeton, jti, exp), …]."""
from backend import draco_session
profil = client.get(f"{BASE}/{username}",
headers=MACHINE_HEADERS).json()
return [draco_session.issue_token(
{"id": profil["id"], "username": username,
"display_name": profil["display_name"]})
for _ in range(nombre)]
class TestSessions:
def test_liste_sessions(self, machine_client):
from datetime import datetime
assert _creer(machine_client).status_code == 201
emis = _emettre(machine_client, "nouveau", 2)
resp = machine_client.get(f"{BASE}/nouveau/sessions",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "nouveau"
assert len(data["sessions"]) == 2
jtis = [s["jti"] for s in data["sessions"]]
assert jtis == [j for _t, j, _e in emis] # exp croissant
for session, (_t, _j, exp) in zip(data["sessions"], emis):
assert session["exp"] == exp
assert session["revoquee"] is False
echeance = datetime.fromisoformat(session["expire_le"])
assert echeance.tzinfo is not None
assert int(echeance.timestamp()) == exp
def test_liste_sessions_vide_et_compte_inconnu(self, machine_client):
resp = machine_client.get(f"{BASE}/mfauser/sessions",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"username": "mfauser", "sessions": []}
resp = machine_client.get(f"{BASE}/personne/sessions",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
def test_revocation_unitaire(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
(t1, jti1, _e1), (t2, _jti2, _e2) = _emettre(
machine_client, "nouveau", 2)
resp = machine_client.post(
f"{BASE}/nouveau/sessions/{jti1}/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"jti": jti1, "revoquee": True}
# La session révoquée est morte, l'autre vit ; la liste la
# signale comme révoquée.
assert draco_session.verify_token(t1) is None
assert draco_session.verify_token(t2) is not None
sessions = machine_client.get(
f"{BASE}/nouveau/sessions", headers=MACHINE_HEADERS).json()
etats = {s["jti"]: s["revoquee"] for s in sessions["sessions"]}
assert etats[jti1] is True
def test_revocation_cross_utilisateur_404(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
(token, jti, _e), = _emettre(machine_client, "nouveau", 1)
# Le jti de « nouveau » demandé sous « mfauser » : 404, et la
# session de « nouveau » reste valide (jamais de révocation
# cross-utilisateur).
resp = machine_client.post(
f"{BASE}/mfauser/sessions/{jti}/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
assert draco_session.verify_token(token) is not None
# Jti inconnu et compte inconnu : 404 également.
resp = machine_client.post(
f"{BASE}/nouveau/sessions/jti-inconnu/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
resp = machine_client.post(
f"{BASE}/personne/sessions/{jti}/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
def test_revoquer_toutes_sans_desactiver(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
emis = _emettre(machine_client, "nouveau", 2)
(autre_token, _j, _e), = _emettre(machine_client, "mfauser", 1)
resp = machine_client.post(
f"{BASE}/nouveau/sessions/revoquer-toutes",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"revoquees": 2}
for token, _jti, _exp in emis:
assert draco_session.verify_token(token) is None
# Les sessions des autres comptes ne sont pas touchées.
assert draco_session.verify_token(autre_token) is not None
# Plus rien à révoquer : le registre a été vidé pour ce compte
# (avant toute nouvelle connexion, qui émettrait une session).
resp = machine_client.post(
f"{BASE}/nouveau/sessions/revoquer-toutes",
headers=MACHINE_HEADERS)
assert resp.json() == {"revoquees": 0}
# Déconnexion globale ≠ désactivation : le compte reste actif
# et une nouvelle connexion fonctionne.
profil = machine_client.get(f"{BASE}/nouveau",
headers=MACHINE_HEADERS).json()
assert profil["active"] is True
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 200
def test_sessions_protegees_par_cle_machine(self, machine_client,
monkeypatch):
assert machine_client.get(
f"{BASE}/nouveau/sessions").status_code == 401
assert machine_client.post(
f"{BASE}/nouveau/sessions/un-jti/revoquer").status_code == 401
assert machine_client.post(
f"{BASE}/nouveau/sessions/revoquer-toutes").status_code == 401
monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY")
assert machine_client.get(
f"{BASE}/nouveau/sessions",
headers=MACHINE_HEADERS).status_code == 404
def test_sessions_journalisees(self, machine_client):
assert _creer(machine_client).status_code == 201
(_t, jti, _e), = _emettre(machine_client, "nouveau", 1)
machine_client.post(f"{BASE}/nouveau/sessions/{jti}/revoquer",
headers=MACHINE_HEADERS)
machine_client.post(f"{BASE}/nouveau/sessions/revoquer-toutes",
headers=MACHINE_HEADERS)
audit_file = Path("data/audit.log")
entrees = [json.loads(ligne)
for ligne in audit_file.read_text(
encoding="utf-8").splitlines() if ligne.strip()]
actions = {(e["action"], e["cible"]) for e in entrees
if e.get("cible") == "nouveau"}
assert ("session_revoquee", "nouveau") in actions
assert ("sessions_revoquees", "nouveau") in actions
# ── Journalisation ──────────────────────────────────────────────────────
class TestJournalisation:
def test_actions_consignees(self, machine_client):
assert _creer(machine_client).status_code == 201
machine_client.post(f"{BASE}/nouveau/desactiver",
headers=MACHINE_HEADERS)
audit_file = Path("data/audit.log")
assert audit_file.exists()
entrees = [json.loads(ligne)
for ligne in audit_file.read_text(
encoding="utf-8").splitlines() if ligne.strip()]
actions = {(e["action"], e["cible"]) for e in entrees
if e.get("cible") == "nouveau"}
assert ("compte_cree", "nouveau") in actions
assert ("compte_desactive", "nouveau") in actions
for e in entrees:
if e.get("cible") == "nouveau":
assert e["acteur"] == "hub-test"
assert e["timestamp"]
+95
View File
@@ -101,6 +101,38 @@ def test_barre_v030_liens_hub_menu_compte():
assert "v0.3.0" in html
def test_barre_v060_entree_administration():
# Barre v0.6.0 (dracodev-commons, demande de Bruno du
# 2026-11-06) : entrée « Administration » du menu compte —
# lien Hub <hub>/admin à visibilité conditionnelle. Le JS
# vendangé la construit (data-hub-link="admin"), masquée tant
# que l'hôte n'affirme pas le statut d'administrateur
# (cfg.admin / DRACO_BAR.setAdmin) ; le CSS porte la règle
# [hidden] qui la masque malgré le display:flex des items.
# Branchement hôte : index.html affirme le statut depuis le
# rôle global de /api/auth/me — la définition même de
# l'administrateur de suite (garde /admin/* du Hub).
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert 'data-hub-link", "admin"' in js
assert '"Administration"' in js
assert "setAdmin" in js
assert "compteAdmin = cfg.admin === true" in js
css = _css()
assert ".draco-menu-item[hidden]" in css
html = _index()
assert 'window.DRACO_BAR.setAdmin(u.role === "admin")' in html
# Le fetch d'amorçage d'index.html tourne avant toute
# connexion : le branchement qui couvre la connexion par le
# formulaire vit dans renderUserSection() (js/auth.js — le
# frontend servi est en modules ES ; le app.js monolithique
# à la racine n'est plus chargé), appelée par showApp() pour
# toutes les sessions établies, au même endroit que la
# rangée d'administration native.
auth_js = (FRONTEND / "js" / "auth.js").read_text(encoding="utf-8")
assert "window.DRACO_BAR.setAdmin(user.role === \"admin\")" in auth_js
assert "window.DRACO_BAR.setAdmin(false)" in auth_js
def test_panneau_spark_integre_218():
# #218 : le bouton Spark de la barre ouvre le panneau de l'agent
# commun (mêmes assets partagés que TikTak / Flowdeck / Kanbante).
@@ -184,6 +216,16 @@ def test_urls_ecosysteme_via_injection_hote_223():
assert "DRACO_URLS[a.dataset.app]" in html
def test_barre_deconnexion_branchee():
# BUG-122 (fusion #141) : la barre d'ObsiGate elle-même branche
# l'action « deconnexion » sur son point d'entrée de déconnexion
# globale (GET /sso/logout, retour à la racine) — avant, le clic
# ne faisait rien ici non plus.
html = _index()
assert 'act === "deconnexion"' in html
assert '"/sso/logout?return_to="' in html
def test_rail_mobile_vers_le_vrai_hamburger():
html = _index()
assert 'matchMedia("(max-width: 768px)")' in html
@@ -206,3 +248,56 @@ def test_panneau_version_service_affichee_225():
# 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
def test_barre_v040_icones_monoline_menu_compte():
# v0.4.0 (HUB-11, fusion #125) : les items du menu compte
# portent des icônes SVG monoline construites en DOM, dans le
# style des icônes du rail de la console — aucun emoji, aucune
# icône pleine. La vendange reste verbatim commons.
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "MENU_ICONES" in js
for nom in ("telecharger", "a-propos", "aide", "profil", "deconnexion"):
assert nom in js
assert "draco-menu-icone" in js
assert "stroke: currentColor" not in js # attributs posés en DOM
assert 'setAttribute("stroke", "currentColor")' in js
assert 'setAttribute("fill", "none")' in js
css = _css()
assert ".draco-menu-icone" in css
bloc = css.split(".draco-menu-icone {", 1)[1].split("}", 1)[0]
assert "width: 18px" in bloc
assert "var(--ink-2)" in bloc
def test_barre_v041_correctif_icones_espace_de_noms_svg():
# v0.4.1 (correctif fusion #125) : les icônes du menu compte
# sont créées via createElementNS (espace de noms SVG). En
# v0.4.0, createElement() les créait dans l'espace de noms
# HTML : jamais dessinées par le navigateur, libellés
# décalés d'un cran d'icône sans aucun glyphe.
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert 'createElementNS(SVG_NS, "svg")' in js
assert "createElementNS(SVG_NS, spec[0])" in js
assert 'createElement("svg")' not in js
assert "createElement(spec[0])" not in js
def test_barre_v050_theme_commun_hub18():
# v0.5.0 (HUB-18, fusion #132) : la barre apprend la préférence
# de thème du Hub et l'applique à l'ouverture. La vendange reste
# verbatim commons ; l'initialisation propre à ObsiGate vit dans
# app.js (écouteur draco:theme → applyTheme).
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "api/profil/theme" in js
assert 'credentials: "include"' in js
assert "draco-theme-hub" in js
assert "THEME_CACHE_TTL_MS" in js
assert 'CustomEvent("draco:theme"' in js
assert "themePref" in js and "themeMode" in js
# Aucun nom de Hub figé de plus que les défauts/dérivations
# documentés : l'URL du Hub reste la résolue de la barre.
assert "resolvedUrls.hub" in js
app = (FRONTEND / "app.js").read_text(encoding="utf-8")
assert 'addEventListener("draco:theme"' in app
assert "barre.themeMode" in app
+97
View File
@@ -18,9 +18,13 @@ def _draco_env(monkeypatch, tmp_path):
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
monkeypatch.setattr(draco_session, "REVOKED_FILE",
tmp_path / "draco_revoked.json")
monkeypatch.setattr(draco_session, "ISSUED_FILE",
tmp_path / "draco_issued.json")
draco_session._revoked = None
draco_session._issued = None
yield
draco_session._revoked = None
draco_session._issued = None
# ── Jeton : émission / vérification ─────────────────────────────────────
@@ -185,6 +189,44 @@ def test_sso_authorize_liste_blanche(client):
assert r.headers["location"] == "/" # refusé, pas de relais
# ── Déconnexion globale navigable (BUG-122, fusion #141) ────────────────
def test_sso_logout_revoque_et_redirige(admin_client):
token = _token_depuis_login(admin_client)
assert draco_session.verify_token(token)
r = admin_client.get(
"/sso/logout",
params={"return_to": "https://tiktak.dracodev.net/"},
headers={"Cookie": f"draco_session={token}"},
follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "https://tiktak.dracodev.net/"
assert draco_session.verify_token(token) is None # jti révoqué
cookie = _draco_cookie(r) # cookie effacé
assert cookie is not None and "Max-Age=0" in cookie
def test_sso_logout_liste_blanche(admin_client):
r = admin_client.get("/sso/logout",
params={"return_to": "https://evil.com/phish"},
follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/" # refusé, pas de relais
r = admin_client.get("/sso/logout", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/" # sans return_to
def test_sso_logout_sans_session_idempotent(client):
# Aucun cookie : la route reste une redirection propre (les autres
# applications y naviguent même si la session a déjà expiré).
r = client.get("/sso/logout",
params={"return_to": "https://hub.dracodev.net/"},
follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "https://hub.dracodev.net/"
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
def test_me_avec_cookie_draco_seul(admin_client):
@@ -232,3 +274,58 @@ def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
headers={"X-Draco-Machine-Key": "cle-machine"})
assert r.status_code == 200
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
# ── Registre des sessions (HUB-22, #227) ────────────────────────────────
AUTRE = {"id": "user-2", "username": "autre", "display_name": "Autre"}
def test_sessions_emises_listees_triees_et_marquees():
_t1, jti1, exp1 = draco_session.issue_token(USER)
t2, jti2, exp2 = draco_session.issue_token(USER)
draco_session.issue_token(AUTRE) # autre sub : exclu
draco_session.revoke(jti2, exp2)
sessions = draco_session.issued_sessions_for_user("admin-1")
assert [s["jti"] for s in sessions] == [jti1, jti2] # exp croissant
assert sessions[0] == {"jti": jti1, "exp": exp1, "revoquee": False}
assert sessions[1] == {"jti": jti2, "exp": exp2, "revoquee": True}
assert draco_session.verify_token(t2) is None # cohérent
def test_sessions_emises_expirees_purgees():
now = int(time.time())
with draco_session._lock:
store = draco_session._load_issued()
store["admin-1"] = {"ancien": now - 10, "frais": now + 600}
assert [s["jti"] for s in
draco_session.issued_sessions_for_user("admin-1")] == ["frais"]
with draco_session._lock:
assert "ancien" not in draco_session._load_issued()["admin-1"]
assert draco_session.issued_sessions_for_user("personne") == []
assert draco_session.issued_sessions_for_user("") == []
def test_revoquer_une_session_appartenante():
token, jti, _exp = draco_session.issue_token(USER)
assert draco_session.revoke_one_for_user("admin-1", jti) is True
assert draco_session.verify_token(token) is None
assert draco_session.is_revoked(jti)
def test_revoquer_une_session_cross_utilisateur_refusee():
token, jti, _exp = draco_session.issue_token(USER)
# Le jti existe, mais pas dans l'ensemble émis de cet autre sub.
assert draco_session.revoke_one_for_user("user-2", jti) is False
assert draco_session.verify_token(token) is not None
assert draco_session.revoke_one_for_user("admin-1", "inconnu") is False
assert draco_session.revoke_one_for_user("", jti) is False
def test_revoquer_une_session_expiree_refusee():
now = int(time.time())
with draco_session._lock:
draco_session._load_issued()["admin-1"] = {"perime": now - 10}
assert draco_session.revoke_one_for_user("admin-1", "perime") is False
assert not draco_session.is_revoked("perime")
+291
View File
@@ -0,0 +1,291 @@
# tests/test_hub_roles.py
# Hub HUB-19 (propagation, #228) — Rôles par application : client du
# registre du Hub (parsing, cache 60 s par sub, repli systématique) et
# effets sur le chemin draco d'ObsiGate (refus « aucun », garde
# administrateur des routes /api/auth/admin/* pour le canal draco).
# Contrat posé par l'hôte pilote ShaarIt 0.7.0 (décision D3(a)).
import io
import json
import time
import urllib.error
import pytest
from backend import draco_session, hub_roles
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
USER_SIMPLE = {"id": "user-1", "username": "normaluser", "display_name": "normal"}
@pytest.fixture(autouse=True)
def _hub_env(monkeypatch):
"""Cache vierge + Hub de test explicite pour chaque test."""
hub_roles.reinitialiser_cache()
monkeypatch.setenv("DRACO_HUB_URL", "https://hub-test.dracodev.net")
yield
hub_roles.reinitialiser_cache()
def _reponse(role, statut=200, corps=None):
"""Fausse réponse urlopen portant un payload du contrat D3(a)."""
if corps is None:
corps = {"app": "obsigate", "sub": "admin-1", "role": role,
"explicite": True, "ttl_secondes": 60}
charge = json.dumps(corps).encode("utf-8")
class _Resp:
status = statut
def read(self):
return charge
def __enter__(self):
return self
def __exit__(self, *exc):
return False
return _Resp()
def _patch_hub(monkeypatch, role, appels):
"""Remplace la consultation HTTP par un rôle fixe (compte les appels)."""
def _faux(url, cookie):
appels.append((url, cookie))
return role
monkeypatch.setattr(hub_roles, "_consulter_hub", _faux)
# ── Client : parsing du contrat ─────────────────────────────────────────
def test_consultation_parse_le_contrat(monkeypatch):
vu = {}
def _faux_urlopen(requete, timeout=None):
vu["url"] = requete.full_url
vu["cookie"] = requete.get_header("Cookie")
vu["accept"] = requete.get_header("Accept")
vu["timeout"] = timeout
return _reponse("administrateur")
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _faux_urlopen)
role = hub_roles.role_pour("admin-1", "draco_session=jeton; autre=1")
assert role == "administrateur"
assert vu["url"] == "https://hub-test.dracodev.net/api/roles/obsigate"
assert vu["cookie"] == "draco_session=jeton; autre=1" # Cookie complet relayé
assert vu["accept"] == "application/json"
assert vu["timeout"] == 2 # délai du contrat
@pytest.mark.parametrize("role", ["aucun", "utilisateur", "administrateur"])
def test_consultation_roles_reconnus(monkeypatch, role):
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
lambda requete, timeout=None: _reponse(role))
assert hub_roles.role_pour("admin-1", "c=1") == role
def test_consultation_role_hors_contrat_inconnu(monkeypatch):
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
lambda requete, timeout=None: _reponse("super-admin"))
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_consultation_json_illisible_inconnu(monkeypatch):
class _Resp:
status = 200
def read(self):
return b"pas du json"
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
lambda requete, timeout=None: _Resp())
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_consultation_erreur_http_inconnu(monkeypatch):
def _erreur(requete, timeout=None):
raise urllib.error.HTTPError(requete.full_url, 500, "boom", {}, io.BytesIO(b""))
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _erreur)
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_consultation_hub_injoignable_inconnu(monkeypatch):
def _panne(requete, timeout=None):
raise urllib.error.URLError("injoignable")
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _panne)
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_url_hub_derivee_par_hote_soeur(monkeypatch):
# Sans variable explicite : dérivation depuis l'hôte de l'agent
# (agent-dev → hub-dev), comme la barre commune (#223).
monkeypatch.delenv("DRACO_HUB_URL")
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert appels[0][0] == "https://hub-dev.dracodev.net/api/roles/obsigate"
def test_url_hub_defaut_officiel(monkeypatch):
monkeypatch.delenv("DRACO_HUB_URL")
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
hub_roles.role_pour("admin-1", "c=1")
assert appels[0][0] == "https://hub.dracodev.net/api/roles/obsigate"
# ── Client : cache 60 s par sub, horodatage avant l'appel ──────────────
def test_cache_par_sub_et_ttl(monkeypatch):
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert len(appels) == 1 # 2e lecture : cache
assert hub_roles.role_pour("user-1", "c=2") == "utilisateur"
assert len(appels) == 2 # autre sub : nouvel appel
# TTL expiré → nouvel appel.
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert len(appels) == 3
def test_echec_conserve_ancien_cache_sans_rafale(monkeypatch):
appels = []
_patch_hub(monkeypatch, "administrateur", appels)
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
assert len(appels) == 1
# Le Hub tombe ; le TTL expire : un appel est tenté, échoue, et
# l'horodatage posé avant l'appel empêche la rafale — l'ancien
# rôle connu n'est ni effacé ni réinterrogé aussitôt.
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
_patch_hub(monkeypatch, None, appels)
assert hub_roles.role_pour("user-1", "c=2") is None
assert len(appels) == 2
# Dans la fenêtre qui suit l'échec, l'ancien cache sert toujours.
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
assert len(appels) == 2
def test_sub_vide_aucun_appel(monkeypatch):
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
assert hub_roles.role_pour("", "c=1") is None
assert appels == []
# ── Intégration : chemin draco (admin_client, secret draco posé) ──────
@pytest.fixture()
def _draco_env(monkeypatch, tmp_path):
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
monkeypatch.setattr(draco_session, "REVOKED_FILE",
tmp_path / "draco_revoked.json")
monkeypatch.setattr(draco_session, "ISSUED_FILE",
tmp_path / "draco_issued.json")
draco_session._revoked = None
draco_session._issued = None
yield
draco_session._revoked = None
draco_session._issued = None
def _pose_cookie_draco(client, user):
token, _jti, _exp = draco_session.issue_token(user)
client.cookies.set("draco_session", token)
return token
def test_draco_aucun_refuse_session_explicite(admin_client, _draco_env, monkeypatch):
appels = []
_patch_hub(monkeypatch, "aucun", appels)
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/me")
assert r.status_code == 403
assert "niveau « aucun »" in r.json()["detail"]
assert "registre des rôles du Hub" in r.json()["detail"]
assert appels and appels[0][1].find("draco_session=") >= 0 # Cookie relayé
def test_draco_aucun_mais_connexion_native_intacte(admin_client, _draco_env, monkeypatch):
# L'authentification native (émettrice, accès de secours) n'est
# jamais touchée par le registre : le login par mot de passe du
# même compte passe, même avec un niveau « aucun » au Hub.
_patch_hub(monkeypatch, "aucun", [])
r = admin_client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert r.status_code == 200, r.text
# Et la session native ainsi établie ouvre /me (canal natif, le
# rôle du Hub ne s'applique pas).
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
def test_draco_role_inconnu_comportement_actuel(admin_client, _draco_env, monkeypatch):
_patch_hub(monkeypatch, None, []) # Hub injoignable
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
# Canal draco + rôle inconnu : les routes admin gardent les
# règles actuelles (le rôle local admin suffit).
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 200, r.text
def test_draco_utilisateur_admin_local_refuse_admin(admin_client, _draco_env, monkeypatch):
# Compte local admin authentifié par draco, mais niveau Hub
# « utilisateur » : /api/auth/admin/* exige « administrateur ».
_patch_hub(monkeypatch, "utilisateur", [])
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text # session draco OK
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 403
assert "administrateur" in r.json()["detail"]
def test_draco_administrateur_hub_admin_ok(admin_client, _draco_env, monkeypatch):
_patch_hub(monkeypatch, "administrateur", [])
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 200, r.text
assert any(u["username"] == "admin" for u in r.json())
def test_draco_utilisateur_simple_admin_refuse_comme_avant(admin_client, _draco_env, monkeypatch):
# Compte local non admin, niveau Hub « administrateur » : le
# registre n'élève aucun privilège local — refus par la règle
# actuelle (rôle local), comme avant HUB-19.
_patch_hub(monkeypatch, "administrateur", [])
_pose_cookie_draco(admin_client, USER_SIMPLE)
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 403
assert r.json()["detail"] == "Accès admin requis"
def test_natif_admin_sans_draco_jamais_bloque_par_hub(admin_client, _draco_env, monkeypatch):
# Session native (login) + niveau Hub « utilisateur » : le canal
# n'est pas draco, la garde du registre ne s'applique pas.
_patch_hub(monkeypatch, "utilisateur", [])
r = admin_client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert r.status_code == 200, r.text
# Le jeton natif valide prime : get_current_user ne passe pas par
# le repli draco, le dict utilisateur n'a ni _draco_sub ni
# _hub_role — la garde du registre ne peut pas s'appliquer.
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 200, r.text