Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03a1c9f6f5 | ||
|
|
045abd6a3c | ||
|
|
06855937bd | ||
|
|
854ff0f904 | ||
|
|
5a5d7a5c59 | ||
|
|
b8949b2ddc | ||
|
|
15d015e59b | ||
|
|
dcf847e2fd | ||
|
|
a3f76e9b0c | ||
|
|
c157a22668 | ||
|
|
cc3707be7a | ||
|
|
75c6f61235 | ||
|
|
8d7692c6df | ||
|
|
318a20c652 | ||
|
|
006cbeed64 | ||
|
|
92ad47fc99 | ||
|
|
84648591ea | ||
|
|
308f99cf63 | ||
|
|
3bfda066ab | ||
|
|
0230f4fa72 | ||
|
|
9f6f366870 | ||
|
|
44a8154cae | ||
|
|
222cd0b562 | ||
|
|
5127510509 | ||
|
|
3876f0e9c3 | ||
|
|
c8d2eb19db | ||
|
|
2943e40e81 | ||
|
|
128deb248d | ||
|
|
151d9faeab |
Regular → Executable
Regular → Executable
+35
-1
@@ -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.84.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.86.2**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,40 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.86.2] — 2026-11-08
|
||||
|
||||
- **BUG-123 — Aller-retour SSO refermé aussi quand la session ObsiGate existe déjà** (chantier « entrée SSO de bout en bout », écart constaté par Bruno en test réel le 2026-11-08) : `GET /sso/authorize?return_to=…` redirige vers `/?return_to=…`, mais le retour n'était honoré que par `AuthManager.saveToken()` (login frais, MFA, WebAuthn). Pour un utilisateur déjà connecté chez ObsiGate, `initAuth()` restaurait la session — jeton encore valide ou refresh silencieux — et affichait l'accueil sans jamais suivre le `return_to` : l'utilisateur restait chez ObsiGate, et l'application sœur ne recevait jamais son pont (aucune session locale). Correctif (`frontend/js/auth.js`) : `_honorReturnTo()` signale désormais la redirection (valeur de retour), et les deux chemins de restauration d'`initAuth()` l'invoquent avant d'afficher l'application. Les refresh silencieux **en cours de session** ne sont pas touchés : ils ne doivent jamais rediriger. Même liste blanche qu'avant (https + `*.dracodev.net`, jamais l'hôte courant). Garde : `tests/test_draco_barre.py` (`test_barre_v070_setuser_et_retour_sso_restauration`) ; chaîne complète prouvée en navigateur réel dans une pile locale aux noms publics (dossier `preuve-entree-sso`). Fiche : `docs/features/entree-sso-bug-123-124.md`.
|
||||
- **BUG-124 — Le nom du compte rejoint la barre commune dès le login, sans rechargement** (même chantier) : après un login dans la SPA, le menu compte de la barre affichait l'en-tête générique « Compte » et l'avatar neutre « ✦ » jusqu'au rechargement forcé — `renderUserSection()` poussait le rôle (`setAdmin`) mais jamais le nom, et le seul poseur d'identité était le fetch d'amorçage d'`index.html`, limité aux sessions déjà présentes au chargement. Barre commune re-vendangée **verbatim** en **v0.7.0** depuis dracodev-commons (PR commons #15) : nouvelle API `DRACO_BAR.setUser(user)` qui resynchronise l'avatar (initiale, infobulle) et l'en-tête du menu compte immédiatement. Branchement hôte : `renderUserSection()` appelle `setUser(name)` pour toute session établie (et `setUser(null)` sans identité), le fetch d'amorçage d'`index.html` également. Garde : `tests/test_draco_barre.py` (même test).
|
||||
|
||||
---
|
||||
|
||||
## [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`.
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.84.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.86.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.84.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.86.2 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.84.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.86.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.84.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.86.2 | Last updated: September 2026*
|
||||
|
||||
@@ -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-")
|
||||
|
||||
+27
-11
@@ -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,
|
||||
@@ -375,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)
|
||||
@@ -421,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"}
|
||||
|
||||
|
||||
@@ -957,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()
|
||||
|
||||
@@ -967,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:
|
||||
@@ -994,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)
|
||||
@@ -1007,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"]:
|
||||
|
||||
@@ -25,6 +25,21 @@
|
||||
# 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
|
||||
|
||||
@@ -179,6 +194,52 @@ def revoke_all_for_user(sub: str) -> int:
|
||||
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]:
|
||||
@@ -284,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -7,6 +7,12 @@
|
||||
# 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
|
||||
@@ -36,6 +42,7 @@ 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
|
||||
@@ -122,6 +129,16 @@ def _compte_ou_404(username: str) -> dict:
|
||||
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.
|
||||
|
||||
@@ -132,8 +149,7 @@ def _revoquer_sessions(user: dict) -> int:
|
||||
avancé par l'appelant (désactivation, réinitialisation du mot de
|
||||
passe) ou par le drapeau ``active`` tant que le compte est inactif.
|
||||
"""
|
||||
sub = str(user.get("id") or user.get("username") or "")
|
||||
return draco_session.revoke_all_for_user(sub)
|
||||
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
|
||||
|
||||
|
||||
def _audit(request: Request, acteur: str, action: str, cible: str,
|
||||
@@ -349,3 +365,60 @@ async def reinitialiser_mfa(username: str, request: Request,
|
||||
_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}
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.84.0"
|
||||
version = "2.86.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.84.0"
|
||||
version = "2.86.2"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.84.0",
|
||||
"version": "2.86.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -227,6 +227,9 @@ 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 |
|
||||
| *BUG-123* | Aller-retour SSO non refermé quand la session ObsiGate existe déjà : `GET /sso/authorize?return_to=…` dépose l'utilisateur sur `/?return_to=…`, mais le retour n'est honoré que par `AuthManager.saveToken()` (login frais, MFA, WebAuthn) — les chemins de restauration de session d'`initAuth()` (jeton encore valide en sessionStorage, ou refresh silencieux au démarrage) affichent l'accueil sans jamais suivre le `return_to` : l'utilisateur reste chez ObsiGate et, revenu manuellement à l'application sœur, n'y a toujours aucune session locale | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/auth.js` (`initAuth`, `_honorReturnTo`) | Navigateur avec session ObsiGate active → Kanbante → « Se connecter avec ObsiGate (SSO) » → retour automatique attendu dans Kanbante, connecté ; constaté en pile locale complète (chaîne d'URL journalisée) : arrivée sur l'accueil ObsiGate, retour jamais suivi | `_honorReturnTo()` retourne si la redirection a eu lieu ; les deux chemins de restauration d'`initAuth()` l'invoquent avant `showApp()` (les refresh en cours de session restent sans redirection). Garde : `tests/test_draco_barre.py` ; preuve navigateur complète du chantier (pile locale aux noms publics) | Signalé par Bruno le 2026-11-08 (test réel, compte « testeur » créé au Hub) ; le sous-cas login frais refermait déjà la boucle (vérifié). Fiche `docs/features/entree-sso-bug-123-124.md`. Redéploiement Docker requis |
|
||||
| *BUG-124* | Nom du compte absent du menu de la barre commune juste après le login : `renderUserSection()` met à jour le bloc latéral et le drapeau admin de la barre, mais ne pousse jamais le nom — le seul poseur d'identité de la barre est le fetch d'amorçage au `DOMContentLoaded`, limité aux sessions déjà présentes au chargement. Après un login SPA sans rechargement : avatar « ✦ » et en-tête « Compte » jusqu'au rechargement forcé | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/auth.js` (`renderUserSection`), `frontend/index.html`, `frontend/draco-barre.js` (vendange commons v0.7.0) | Se connecter dans la SPA puis ouvrir le menu compte de la barre sans recharger : en-tête « Compte » au lieu du nom (reproduit en pile locale, capture) | Barre commune v0.7.0 re-vendangée verbatim (nouvelle API `DRACO_BAR.setUser`, PR dracodev-commons #15) ; `renderUserSection()` appelle `setUser(name)` / `setUser(null)`, le fetch d'amorçage d'`index.html` pousse aussi le nom par `setUser`. Garde : `tests/test_draco_barre.py` | Signalé par Bruno le 2026-11-08 (captures). Fiche `docs/features/entree-sso-bug-123-124.md`. Redéploiement Docker requis |
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
@@ -361,6 +364,8 @@ 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) |
|
||||
| 2026-11-08 | BUG-123 + BUG-124 (chantier entrée SSO) | Correction | `frontend/js/auth.js`, `frontend/index.html`, `frontend/draco-barre.js`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/entree-sso-bug-123-124.md`, `VERSION` (+ dérivés) | **BUG-123 — aller-retour SSO refermé à la restauration de session** : `initAuth()` honore le `return_to` sur ses deux chemins de restauration (jeton valide, refresh silencieux au démarrage), comme le faisait déjà `saveToken` après un login frais. **BUG-124 — nom du compte dans la barre dès le login** : barre commune re-vendangée en v0.7.0 (`DRACO_BAR.setUser`, PR dracodev-commons #15), branchée dans `renderUserSection()` et le fetch d'amorçage. Chaînes prouvées en navigateur réel (pile locale aux noms publics, scénario de Bruno) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+43
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.84.0 | **Dernière mise à jour :** 2026-11-06
|
||||
> **Version :** 2.86.2 | **Dernière mise à jour :** 2026-11-08
|
||||
> **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,46 @@
|
||||
|
||||
## 🔵 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)
|
||||
@@ -1187,6 +1227,8 @@
|
||||
| BUG-119 | Panneau Spark — « mode console » bloqué par la CSP (`frame-src` sans l'origine du service d'agents) | 2.77.1 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||
| BUG-120 | Menu inter-apps — les URL sœurs (`window.DRACO_URLS`) dérivent l'environnement de l'hôte de l'agent (`-dev` en dev), explicite prioritaire, Gitea inchangée (suite de #223) | 2.78.2 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||
| 225 | Panneau Spark — version du service d'agents visible en mode sidebar (pied du panneau, lue en direct via `GET /healthz`) | 2.79.0 | [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md) |
|
||||
| BUG-123 | Aller-retour SSO — le `return_to` de `/sso/authorize` est honoré aussi après restauration de session au démarrage (`initAuth`), pas seulement après un login frais | Unreleased | [features/entree-sso-bug-123-124.md](./features/entree-sso-bug-123-124.md) |
|
||||
| BUG-124 | Barre commune v0.7.0 — `DRACO_BAR.setUser` branché dans `renderUserSection()` : le nom du compte s'affiche dans le menu compte dès le login, sans rechargement | Unreleased | [features/entree-sso-bug-123-124.md](./features/entree-sso-bug-123-124.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Entrée SSO de bout en bout — BUG-123 + BUG-124
|
||||
|
||||
Chantier « entrée SSO de bout en bout » (écarts constatés par Bruno en
|
||||
test réel le 2026-11-08, compte « testeur » créé au Hub) : deux des
|
||||
trois écarts tenaient à ObsiGate. Diagnostic mené en navigateur réel
|
||||
dans une pile locale complète servie sous les noms publics
|
||||
(`*-dev.dracodev.net`, TLS local), chaîne d'URL et de cookies
|
||||
journalisée à chaque saut — dossier de preuves `preuve-entree-sso`.
|
||||
|
||||
## BUG-123 — L'aller-retour SSO ne se referme pas (session existante)
|
||||
|
||||
**Chaîne observée (cas fautif)** : clic « Se connecter avec
|
||||
ObsiGate (SSO) » dans l'application sœur → `GET
|
||||
/sso/authorize?return_to=<app>/auth/draco` → `302 /?return_to=…` →
|
||||
la SPA restaure la session (`initAuth` : jeton encore valide, ou
|
||||
refresh silencieux via le cookie HttpOnly) et affiche l'accueil —
|
||||
**le `return_to` n'est jamais suivi**. L'utilisateur reste chez
|
||||
ObsiGate ; revenu manuellement à l'application, il n'y trouve
|
||||
aucune session locale (« toujours pas connecté »).
|
||||
|
||||
**Cause racine** : le retour n'était honoré qu'à un seul endroit,
|
||||
`AuthManager.saveToken()` → `_honorReturnTo()` — c'est-à-dire après
|
||||
un login frais, une MFA ou un WebAuthn. Les deux chemins de
|
||||
restauration d'`initAuth()` ne passaient ni par `saveToken` ni par
|
||||
`_honorReturnTo`. Le sous-cas « login frais » refermait la boucle
|
||||
correctement (vérifié dans la même pile) — d'où un bug invisible
|
||||
pour qui teste en navigateur vierge sans session centrale.
|
||||
|
||||
**Correctif** (`frontend/js/auth.js`) :
|
||||
|
||||
- `_honorReturnTo()` retourne `true` quand il redirige (même liste
|
||||
blanche inchangée : https + `*.dracodev.net`, jamais l'hôte
|
||||
courant — re-validation cliente en plus de celle du serveur).
|
||||
- `initAuth()` invoque `_honorReturnTo()` sur ses deux chemins de
|
||||
restauration réussie, avant `showApp()`.
|
||||
- Les refresh silencieux **en cours de session** (`refreshAccessToken`
|
||||
appelé par l'intercepteur) n'honorent toujours pas le retour :
|
||||
une page déjà ouverte ne doit jamais être redirigée.
|
||||
|
||||
## BUG-124 — Le nom du compte absent du menu de la barre après login
|
||||
|
||||
**Constat** : juste après le login, le menu compte de la barre
|
||||
commune affiche l'en-tête générique « Compte » et l'avatar neutre
|
||||
« ✦ » ; un rechargement forcé fait apparaître le nom.
|
||||
|
||||
**Cause racine** : la barre résout le nom dans `cfg.user`, sinon
|
||||
dans le `title` de l'avatar. Chez ObsiGate, le seul poseur du
|
||||
`title` était le fetch d'amorçage d'`index.html` au
|
||||
`DOMContentLoaded` — limité aux sessions **déjà présentes au
|
||||
chargement**. `renderUserSection()` (`js/auth.js`), appelée pour
|
||||
toutes les sessions établies (login, restauration), mettait à jour
|
||||
le bloc latéral et le drapeau admin de la barre (`setAdmin`,
|
||||
v0.6.0/fusion #142) mais jamais le nom : la barre n'avait aucune
|
||||
API pour le recevoir.
|
||||
|
||||
**Correctif** :
|
||||
|
||||
- dracodev-commons **v0.7.0** (PR #15) : API publique
|
||||
`DRACO_BAR.setUser(user)` — même contrat de valeur que `cfg.user`
|
||||
(chaîne, ou objet `display_name` / `name` / `username`) ;
|
||||
resynchronise l'avatar (initiale, infobulle, aria-label) et
|
||||
l'en-tête du menu compte immédiatement ; `setUser(null)` rétablit
|
||||
l'état neutre. Barre re-vendangée verbatim dans
|
||||
`frontend/draco-barre.js`.
|
||||
- `renderUserSection()` appelle `setUser(name)` pour toute session
|
||||
établie et `setUser(null)` sans identité ; le fetch d'amorçage
|
||||
d'`index.html` pousse aussi le nom par `setUser` (le `title` seul
|
||||
ne resynchronisait ni l'aria-label ni un menu déjà construit).
|
||||
|
||||
## Vérifications
|
||||
|
||||
- `tests/test_draco_barre.py::test_barre_v070_setuser_et_retour_sso_restauration`
|
||||
(contrats épinglés) + suites draco/session/version vertes.
|
||||
- `node ui/barre-apps/tests/barre-apps.test.js` côté commons :
|
||||
58 tests, 284 assertions (nouveau test setUser).
|
||||
- Preuve navigateur de bout en bout (scénario de Bruno, contexte
|
||||
vierge) : voir le rapport du chantier et `preuve-entree-sso/`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
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 {
|
||||
|
||||
@@ -36,6 +36,39 @@
|
||||
* 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é.
|
||||
* v0.7.0 (chantier « entrée SSO de bout en bout », 2026-11-08) :
|
||||
* l'API publique gagne DRACO_BAR.setUser(user) — l'hôte pousse le
|
||||
* nom du compte courant DÈS qu'il l'établit, notamment une SPA qui
|
||||
* termine son login sans rechargement (cas ObsiGate : le nom
|
||||
* n'atteignait la barre qu'au chargement de la page suivante, le
|
||||
* menu compte affichait l'en-tête générique « Compte » jusqu'au
|
||||
* rechargement forcé). Même contrat de valeur que cfg.user
|
||||
* (chaîne, ou objet display_name / name / username). L'avatar
|
||||
* (initiale, infobulle) et l'en-tête du menu compte sont mis à
|
||||
* jour immédiatement, sans rechargement ; null rétablit l'état
|
||||
* neutre (✦, « Compte »).
|
||||
*
|
||||
* 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
|
||||
@@ -48,6 +81,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/",
|
||||
@@ -268,6 +306,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. */
|
||||
@@ -522,6 +685,18 @@
|
||||
["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" }],
|
||||
@@ -556,6 +731,15 @@
|
||||
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
|
||||
@@ -588,6 +772,22 @@
|
||||
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";
|
||||
@@ -603,6 +803,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) {
|
||||
@@ -695,12 +896,41 @@
|
||||
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;
|
||||
},
|
||||
/* Identité (v0.7.0) : l'hôte pousse le nom du compte courant
|
||||
* dès qu'il l'établit — même contrat de valeur que cfg.user
|
||||
* (chaîne, ou objet display_name / name / username). L'avatar
|
||||
* et l'en-tête du menu compte sont resynchronisés aussitôt,
|
||||
* sans rechargement. setUser(null) rétablit l'état neutre. */
|
||||
setUser: function (u) {
|
||||
cfg.user = u || null;
|
||||
if (!u) {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
if (av) {
|
||||
av.textContent = "✦";
|
||||
av.removeAttribute("title");
|
||||
av.setAttribute("aria-label", "Compte");
|
||||
}
|
||||
}
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -709,6 +939,7 @@
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
initThemeCommun();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
@@ -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,24 @@
|
||||
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");
|
||||
}
|
||||
/* Barre v0.7.0 (commons, BUG-124) : le nom du
|
||||
compte est aussi poussé par setUser — le
|
||||
title seul ne resynchronisait ni l'aria-label
|
||||
ni un menu compte déjà construit. */
|
||||
if (u && nom && window.DRACO_BAR && window.DRACO_BAR.setUser) {
|
||||
window.DRACO_BAR.setUser(nom);
|
||||
}
|
||||
}).catch(function () { /* avatar générique conservé */ });
|
||||
});
|
||||
</script>
|
||||
|
||||
+52
-1
@@ -131,15 +131,17 @@ const AuthManager = {
|
||||
_honorReturnTo() {
|
||||
try {
|
||||
const rt = new URLSearchParams(window.location.search).get("return_to");
|
||||
if (!rt) return;
|
||||
if (!rt) return false;
|
||||
const u = new URL(rt);
|
||||
const okHost = u.hostname === "dracodev.net"
|
||||
|| u.hostname.endsWith(".dracodev.net");
|
||||
if (u.protocol === "https:" && okHost
|
||||
&& u.hostname !== window.location.hostname) {
|
||||
window.location.replace(rt);
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* return_to illisible : on reste sur place */ }
|
||||
return false;
|
||||
},
|
||||
|
||||
getToken() {
|
||||
@@ -386,6 +388,17 @@ 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);
|
||||
}
|
||||
// Barre commune v0.7.0 (BUG-124) : symétriquement, le nom
|
||||
// du compte est retiré de la barre (retour au neutre).
|
||||
if (window.DRACO_BAR && window.DRACO_BAR.setUser) {
|
||||
window.DRACO_BAR.setUser(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!section) return;
|
||||
@@ -427,6 +440,29 @@ 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");
|
||||
}
|
||||
// Barre commune v0.7.0 (BUG-124) : pousser aussi le NOM du
|
||||
// compte, pour le même motif — sans cet appel, la barre ne
|
||||
// connaissait l'identité que par le fetch d'amorçage
|
||||
// d'index.html (sessions déjà présentes au chargement) :
|
||||
// après un login SPA sans rechargement, le menu compte
|
||||
// affichait l'en-tête générique « Compte » et l'avatar
|
||||
// neutre « ✦ » jusqu'au rechargement forcé.
|
||||
if (window.DRACO_BAR && window.DRACO_BAR.setUser) {
|
||||
window.DRACO_BAR.setUser(name || null);
|
||||
}
|
||||
|
||||
const adminRow = document.getElementById("admin-menu-row");
|
||||
if (adminRow) {
|
||||
if (user.role === "admin") {
|
||||
@@ -469,6 +505,11 @@ const AuthManager = {
|
||||
|
||||
// Auth enabled — check for existing session
|
||||
if (this.hasSession() && !this.isTokenExpired()) {
|
||||
// BUG-123 : session déjà établie au démarrage (jeton encore
|
||||
// valide) — si la page a été ouverte par /sso/authorize avec
|
||||
// un return_to, l'aller-retour SSO doit se refermer ici
|
||||
// aussi, pas seulement après un login frais (saveToken).
|
||||
if (this._honorReturnTo()) return true;
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
@@ -485,6 +526,16 @@ const AuthManager = {
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(user));
|
||||
// BUG-123 : même obligation après restauration par
|
||||
// refresh silencieux au démarrage — refreshAccessToken()
|
||||
// ne passe pas par saveToken, donc sans cet appel le
|
||||
// return_to d'un /sso/authorize restait sans effet et
|
||||
// l'utilisateur demeurait sur l'accueil ObsiGate au lieu
|
||||
// de revenir, connecté, à l'application demanderesse.
|
||||
// NB : seuls les chemins de démarrage d'initAuth honorent
|
||||
// le retour ; les refresh silencieux en cours de session
|
||||
// ne doivent jamais rediriger.
|
||||
if (this._honorReturnTo()) return true;
|
||||
this.showApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.84.0",
|
||||
"version": "2.86.2",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -354,6 +354,163 @@ class TestSuppression:
|
||||
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:
|
||||
|
||||
@@ -101,6 +101,69 @@ 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_barre_v070_setuser_et_retour_sso_restauration():
|
||||
# Barre v0.7.0 (dracodev-commons) + BUG-123/BUG-124 (chantier
|
||||
# « entrée SSO de bout en bout », écarts constatés par Bruno le
|
||||
# 2026-11-08) :
|
||||
# - BUG-124 : le JS vendangé expose DRACO_BAR.setUser ; l'hôte
|
||||
# le branche dans renderUserSection() (toutes les sessions
|
||||
# établies, comme setAdmin) et dans le fetch d'amorçage
|
||||
# d'index.html — sans quoi le menu compte affichait
|
||||
# « Compte » jusqu'au rechargement après un login SPA.
|
||||
# - BUG-123 : _honorReturnTo signale la redirection (return)
|
||||
# et initAuth() l'invoque sur ses deux chemins de
|
||||
# restauration au démarrage (jeton valide, refresh
|
||||
# silencieux) — sans quoi l'aller-retour SSO ne se refermait
|
||||
# pas quand la session ObsiGate existait déjà.
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "setUser: function (u)" in js
|
||||
assert "v0.7.0" in js
|
||||
html = _index()
|
||||
assert "window.DRACO_BAR.setUser(nom)" in html
|
||||
auth_js = (FRONTEND / "js" / "auth.js").read_text(encoding="utf-8")
|
||||
assert "window.DRACO_BAR.setUser(name || null)" in auth_js
|
||||
assert "window.DRACO_BAR.setUser(null)" in auth_js
|
||||
# BUG-123 : le retour est honoré à la restauration, et
|
||||
# _honorReturnTo rapporte s'il a redirigé.
|
||||
assert "if (this._honorReturnTo()) return true;" in auth_js
|
||||
assert auth_js.count("if (this._honorReturnTo()) return true;") == 2
|
||||
assert "window.location.replace(rt);\n return true;" in auth_js
|
||||
# Le login frais continue de passer par saveToken.
|
||||
assert "this._honorReturnTo();\n }," 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 +247,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
|
||||
@@ -239,3 +312,23 @@ def test_barre_v041_correctif_icones_espace_de_noms_svg():
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user