From dcf847e2fd1e8c8ff9af4eacd9820c3bf81b98c5 Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 6 Nov 2026 16:15:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20HUB-19=20=E2=80=94=20r=C3=B4les=20du=20?= =?UTF-8?q?Hub=20consomm=C3=A9s=20par=20ObsiGate=20(registre=20D3(a))=20#2?= =?UTF-8?q?28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/auth/middleware.py | 44 +++- backend/auth/router.py | 14 +- backend/draco_session.py | 33 +++ backend/hub_roles.py | 126 ++++++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 26 ++- docs/features/hub-roles-obsigate-228.md | 77 +++++++ package.json | 2 +- tests/test_hub_roles.py | 291 ++++++++++++++++++++++++ 15 files changed, 618 insertions(+), 20 deletions(-) create mode 100644 backend/hub_roles.py create mode 100644 docs/features/hub-roles-obsigate-228.md create mode 100644 tests/test_hub_roles.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62993a5..32e2fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.85.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.85.1**. --- @@ -14,6 +14,9 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [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`. diff --git a/README.fr.md b/README.fr.md index 3924b74..b735f89 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.85.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.85.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.1). --- -*Projet : ObsiGate | Version : 2.85.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.85.1 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index b506289..4bbc524 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.85.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.85.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.1). --- -*Project: ObsiGate | Version: 2.85.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.85.1 | Last updated: September 2026* diff --git a/VERSION b/VERSION index af87173..d7441bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.85.0 +2.85.1 diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index cc94b81..e94272a 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -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- ».""" return vault_name.startswith("home-") diff --git a/backend/auth/router.py b/backend/auth/router.py index b2731f2..339d56e 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -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, @@ -957,9 +957,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 +971,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 +998,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 +1011,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"]: diff --git a/backend/draco_session.py b/backend/draco_session.py index 465ba6d..2293340 100644 --- a/backend/draco_session.py +++ b/backend/draco_session.py @@ -29,6 +29,17 @@ # (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 @@ -334,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 diff --git a/backend/hub_roles.py b/backend/hub_roles.py new file mode 100644 index 0000000..0b332bc --- /dev/null +++ b/backend/hub_roles.py @@ -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 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 31b988c..666d12f 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.85.0" +version = "2.85.1" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 24be9fa..329b2c1 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.85.0" +version = "2.85.1" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 00e691e..12ae52f 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.85.0", + "version": "2.85.1", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4d577ac..3ab53a0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.85.0 | **Dernière mise à jour :** 2026-11-06 +> **Version :** 2.85.1 | **Dernière mise à jour :** 2026-11-06 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -32,7 +32,7 @@ ### 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 en attente de fusion — voir CHANGELOG ; redéploiement Docker requis) +- **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 @@ -47,6 +47,28 @@ - [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) diff --git a/docs/features/hub-roles-obsigate-228.md b/docs/features/hub-roles-obsigate-228.md new file mode 100644 index 0000000..def0c0a --- /dev/null +++ b/docs/features/hub-roles-obsigate-228.md @@ -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). diff --git a/package.json b/package.json index ffc068e..6e28085 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.85.0", + "version": "2.85.1", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_hub_roles.py b/tests/test_hub_roles.py new file mode 100644 index 0000000..4b5eda5 --- /dev/null +++ b/tests/test_hub_roles.py @@ -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