Merge pull request 'ObsiGate 2.85.1 — HUB-19 : propagation des rôles du Hub (consommatrice, registre D3(a)) (#228)' (#69) from hub19-roles-obsigate-2-85-1 into main
CI / lint (push) Successful in 1m14s
CI / security (push) Successful in 19s
CI / test (push) Successful in 3m34s
CI / build (push) Successful in 11s
CI / e2e (push) Failing after 15m26s

This commit was merged in pull request #69.
This commit is contained in:
2026-11-06 11:31:25 -05:00
15 changed files with 618 additions and 20 deletions
+4 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.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`.
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.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*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.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*
+1 -1
View File
@@ -1 +1 @@
2.85.0
2.85.1
+43 -1
View File
@@ -141,9 +141,26 @@ def _draco_fallback(request: Request) -> dict | None:
return user
def require_auth(current_user=Depends(get_current_user)):
def require_auth(request: Request, current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
# HUB-19 (#228) : la session draco a été refusée parce que le
# registre des rôles du Hub attribue le niveau « aucun » à ce
# compte pour ObsiGate — réponse explicite, distincte du 401
# générique. L'authentification native n'est pas concernée :
# ce refus ne naît que sur le chemin draco (user_for_draco).
if getattr(request.state, "role_hub_refuse", None) == "aucun":
raise HTTPException(
status_code=403,
detail=(
"Accès refusé : le registre des rôles du Hub attribue "
"le niveau « aucun » à votre compte pour ObsiGate, "
"l'accès par la session draco est donc refusé. "
"L'administrateur de la suite peut modifier ce niveau "
"dans la matrice des rôles du Hub. L'accès natif "
"d'ObsiGate (son propre identifiant) reste possible."
),
)
raise HTTPException(
status_code=401,
detail="Authentification requise",
@@ -159,6 +176,31 @@ def require_admin(current_user=Depends(require_auth)):
return current_user
def require_admin_hub(current_user=Depends(require_admin)):
"""Dependency: admin + garde du registre des rôles du Hub (HUB-19, #228).
Pour les routes d'administration des comptes (``/api/auth/admin/*``) :
quand la requête est authentifiée par le **canal draco** (``_draco_sub``
présent) et que le rôle du Hub est **connu** (``_hub_role`` non None),
le niveau ``administrateur`` du registre est exigé — le rôle local ne
suffit plus. Session native, ou rôle inconnu (Hub injoignable) :
règles actuelles inchangées.
"""
if current_user.get("_draco_sub"):
role_hub = current_user.get("_hub_role")
if role_hub is not None and role_hub != "administrateur":
raise HTTPException(
status_code=403,
detail=(
"Accès réservé au niveau « administrateur » d'ObsiGate "
"dans le registre des rôles du Hub (votre niveau actuel : "
f"« {role_hub} »). L'administrateur de la suite peut "
"l'accorder dans la matrice des rôles du Hub."
),
)
return current_user
def is_home_vault(vault_name: str) -> bool:
"""Un dossier personnel (#194) : vault « home-<user> »."""
return vault_name.startswith("home-")
+9 -5
View File
@@ -40,7 +40,7 @@ from .mfa import (
verify_recovery_code,
verify_totp,
)
from .middleware import is_auth_enabled, require_admin, require_auth
from .middleware import is_auth_enabled, require_admin_hub, require_auth
from .password import hash_password, validate_password_strength, verify_password
from .user_store import (
create_user,
@@ -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"]:
+33
View File
@@ -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
+126
View File
@@ -0,0 +1,126 @@
# backend/hub_roles.py
# Hub HUB-19 (propagation, #228) — Rôles par application : consommation
# du registre du Hub par ObsiGate, sur le contrat posé par l'hôte
# pilote ShaarIt (0.7.0, décision D3(a) du plan Hub v2).
#
# À l'ouverture de session draco (validation locale du cookie partagé,
# `draco_session.user_for_draco`), ObsiGate demande au Hub le niveau
# du porteur pour l'application `obsigate` :
# ``GET {hub}/api/roles/obsigate``, en relayant l'en-tête Cookie
# complet de la requête, ``Accept: application/json``, délai 2 s.
# Réponse 200 : ``{"app", "sub", "role", "explicite",
# "ttl_secondes": 60}``, rôle ∈ {aucun, utilisateur, administrateur}.
#
# Cache mémoire par `sub`, TTL 60 s, sur le patron du pilote (et du
# cache de révocations draco) : l'horodatage est posé **avant**
# l'appel — un échec conserve l'ancien cache et ne déclenche pas de
# rafale de requêtes vers un Hub en panne.
#
# **Repli obligatoire** : Hub injoignable, en erreur ou réponse
# illisible → rôle **inconnu** (``None``) et l'appelant garde son
# comportement actuel. Le registre ne crée jamais un verrouillage
# nouveau.
#
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
# la barre commune (#223) : variable explicite `DRACO_HUB_URL` d'abord,
# dérivation par l'hôte sœur (suffixe d'environnement de l'hôte de
# l'agent) ensuite, défaut officiel enfin. Jamais d'URL dev figée.
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.request
logger = logging.getLogger("obsigate.hub_roles")
#: Identifiant d'ObsiGate dans le registre des applications du Hub.
APP = "obsigate"
#: Niveaux reconnus du registre (contrat D3(a)).
ROLES = ("aucun", "utilisateur", "administrateur")
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
CACHE_TTL_SECONDS = 60
#: Délai maximal de la consultation du Hub (contrat : 2 s).
DELAI_SECONDS = 2
_lock = threading.Lock()
_cache: dict[str, str] = {} # sub -> rôle connu
_cache_at: float | None = None # horodatage (monotone) du dernier appel
def _hub_url() -> str:
"""Base publique du Hub, résolue comme pour la barre commune."""
from backend.draco_config import app_url
return app_url("hub")
def _consulter_hub(url: str, cookie: str) -> str | None:
"""Appelle le Hub et retourne le rôle validé, ou ``None``.
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON illisible,
rôle hors contrat) retourne ``None`` : rôle inconnu, repli.
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
quel — c'est lui qui prouve l'identité du porteur au Hub.
"""
requete = urllib.request.Request(
url,
method="GET",
headers={"Cookie": cookie, "Accept": "application/json"},
)
try:
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
if reponse.status != 200:
return None
donnees = json.loads(reponse.read().decode("utf-8"))
except Exception: # le registre ne casse jamais une ouverture de session
return None
if not isinstance(donnees, dict):
return None
role = donnees.get("role")
return role if role in ROLES else None
def role_pour(sub: str, cookie: str) -> str | None:
"""Niveau connu pour ce `sub` : un des ``ROLES`` si le Hub a
répondu, ``None`` si le rôle est inconnu (Hub injoignable/erreur)
— repli = comportement actuel de l'appelant.
`cookie` est l'en-tête Cookie complet de la requête d'origine,
relayé tel quel au Hub.
"""
global _cache_at
if not sub:
return None
with _lock:
if _cache_at is not None \
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS \
and sub in _cache:
return _cache[sub]
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
# cache reste en vigueur, un échec ne déclenche pas de rafale.
_cache_at = time.monotonic()
url = f"{_hub_url().rstrip('/')}/api/roles/{APP}"
role = _consulter_hub(url, cookie)
if role is None:
logger.warning(
"Registre des rôles du Hub indisponible (%s) — "
"comportement actuel conservé", url)
return None
with _lock:
_cache[sub] = role
_cache_at = time.monotonic()
return role
def reinitialiser_cache() -> None:
"""Vide le cache des rôles (tests, redémarrage à chaud)."""
global _cache_at
with _lock:
_cache.clear()
_cache_at = None
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.85.0"
version = "2.85.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.85.0",
"version": "2.85.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+24 -2
View File
@@ -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)
+77
View File
@@ -0,0 +1,77 @@
# #228 — HUB-19 : propagation des rôles du Hub à ObsiGate
**Carte :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) — le pilote
ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate le consomme à
son tour, comme hôte à la fois **émettrice** et **consommatrice** du SSO.
**Version cible :** 2.85.1 (branche depuis le main 2.84.1 ; hypothèse : à
rebaser après la PR « API sessions HUB-22 » — 2.85.0 — si elle fusionne
d'abord).
## Contrat consommé (D3(a))
À l'ouverture de session draco — chez ObsiGate : la validation locale du
cookie partagé, `draco_session.user_for_draco()` (ACC-3.2, #217), appelée
par le repli draco de `get_current_user` — consultation du registre :
- `GET {hub}/api/roles/obsigate`, en-tête **Cookie complet** de la requête
relayé tel quel, `Accept: application/json`, délai **2 s** ;
- réponse 200 : `{"app", "sub", "role", "explicite", "ttl_secondes": 60}`,
`role` ∈ {`aucun`, `utilisateur`, `administrateur`} ;
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de la
barre commune (#223) — `DRACO_HUB_URL` explicite d'abord, dérivation
par l'hôte sœur (suffixe d'environnement de l'hôte de l'agent)
ensuite, défaut officiel enfin. Jamais d'URL dev figée.
## Client — `backend/hub_roles.py`
Sur le patron du pilote (`src/hub_roles.rs` de ShaarIt) :
- cache mémoire par `sub`, TTL **60 s** ;
- horodatage posé **avant** l'appel : un échec conserve l'ancien cache
et ne déclenche pas de rafale vers un Hub en panne ;
- toute erreur (réseau, délai, statut ≠ 200, JSON illisible, rôle hors
contrat) → rôle **inconnu** (`None`) → l'appelant garde son
comportement actuel. Le registre ne crée jamais un verrouillage
nouveau.
## Effets (sessions établies par draco seulement)
1. **Niveau connu `aucun`** → `user_for_draco` refuse : pas de session
établie par draco. `require_auth` répond alors **403** avec un
message explicite en français (niveau « aucun », matrice des rôles
du Hub, accès natif toujours possible), consigné via
`request.state.role_hub_refuse`. L'**authentification native**
d'ObsiGate (son propre login — c'est l'émettrice, son accès propre
est le secours) n'est **jamais** touchée.
2. **Routes d'administration `/api/auth/admin/*`** (CRUD des comptes,
`backend/auth/router.py`) : nouvelle garde `require_admin_hub`
(`backend/auth/middleware.py`) = `require_admin` + , quand la
requête est authentifiée par le **canal draco** (`_draco_sub`
présent, `_hub_role` joint par `user_for_draco`) et que le rôle est
**connu**, exigence du niveau `administrateur` du registre.
Sessions natives → règles actuelles inchangées ; rôle inconnu →
règles actuelles inchangées. Le registre n'élève aucun privilège :
un compte local non admin reste refusé par la règle locale, quel
que soit son niveau au Hub.
3. **Inchangés** : validation locale du jeton, émission du cookie,
révocations (`jti`), renouvellement glissant. **Aucun rôle dans le
jeton** (claims minimaux de #216 préservés).
Périmètre assumé : la garde du registre vise les routes nommées par la
carte, `/api/auth/admin/*`. Les autres surfaces gardées par
`require_admin` (configuration, vaults, coffres…) gardent leurs règles
actuelles ; les y soumettre serait un changement d'une ligne par
surface (`require_admin_hub` est réutilisable tel quel) si Bruno le
décide.
## Tests
`tests/test_hub_roles.py` : parsing du contrat (URL, Cookie relayé,
`Accept`, délai 2 s), rôles reconnus / hors contrat, JSON illisible,
erreurs HTTP et réseau → inconnu ; résolution de l'URL du Hub
(explicite, dérivée `-dev`, défaut officiel) ; cache par `sub`, TTL,
échec sans rafale avec ancien cache conservé ; intégration sur le
chemin draco (refus « aucun » explicite, login natif intact sous
« aucun », rôle inconnu = comportement actuel, `utilisateur` Hub
refusé sur `/api/auth/admin/*`, `administrateur` Hub accepté, compte
local non admin non élevé, session native jamais bloquée par le Hub).
+1 -1
View File
@@ -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": {
+291
View File
@@ -0,0 +1,291 @@
# tests/test_hub_roles.py
# Hub HUB-19 (propagation, #228) — Rôles par application : client du
# registre du Hub (parsing, cache 60 s par sub, repli systématique) et
# effets sur le chemin draco d'ObsiGate (refus « aucun », garde
# administrateur des routes /api/auth/admin/* pour le canal draco).
# Contrat posé par l'hôte pilote ShaarIt 0.7.0 (décision D3(a)).
import io
import json
import time
import urllib.error
import pytest
from backend import draco_session, hub_roles
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
USER_SIMPLE = {"id": "user-1", "username": "normaluser", "display_name": "normal"}
@pytest.fixture(autouse=True)
def _hub_env(monkeypatch):
"""Cache vierge + Hub de test explicite pour chaque test."""
hub_roles.reinitialiser_cache()
monkeypatch.setenv("DRACO_HUB_URL", "https://hub-test.dracodev.net")
yield
hub_roles.reinitialiser_cache()
def _reponse(role, statut=200, corps=None):
"""Fausse réponse urlopen portant un payload du contrat D3(a)."""
if corps is None:
corps = {"app": "obsigate", "sub": "admin-1", "role": role,
"explicite": True, "ttl_secondes": 60}
charge = json.dumps(corps).encode("utf-8")
class _Resp:
status = statut
def read(self):
return charge
def __enter__(self):
return self
def __exit__(self, *exc):
return False
return _Resp()
def _patch_hub(monkeypatch, role, appels):
"""Remplace la consultation HTTP par un rôle fixe (compte les appels)."""
def _faux(url, cookie):
appels.append((url, cookie))
return role
monkeypatch.setattr(hub_roles, "_consulter_hub", _faux)
# ── Client : parsing du contrat ─────────────────────────────────────────
def test_consultation_parse_le_contrat(monkeypatch):
vu = {}
def _faux_urlopen(requete, timeout=None):
vu["url"] = requete.full_url
vu["cookie"] = requete.get_header("Cookie")
vu["accept"] = requete.get_header("Accept")
vu["timeout"] = timeout
return _reponse("administrateur")
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _faux_urlopen)
role = hub_roles.role_pour("admin-1", "draco_session=jeton; autre=1")
assert role == "administrateur"
assert vu["url"] == "https://hub-test.dracodev.net/api/roles/obsigate"
assert vu["cookie"] == "draco_session=jeton; autre=1" # Cookie complet relayé
assert vu["accept"] == "application/json"
assert vu["timeout"] == 2 # délai du contrat
@pytest.mark.parametrize("role", ["aucun", "utilisateur", "administrateur"])
def test_consultation_roles_reconnus(monkeypatch, role):
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
lambda requete, timeout=None: _reponse(role))
assert hub_roles.role_pour("admin-1", "c=1") == role
def test_consultation_role_hors_contrat_inconnu(monkeypatch):
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
lambda requete, timeout=None: _reponse("super-admin"))
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_consultation_json_illisible_inconnu(monkeypatch):
class _Resp:
status = 200
def read(self):
return b"pas du json"
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
lambda requete, timeout=None: _Resp())
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_consultation_erreur_http_inconnu(monkeypatch):
def _erreur(requete, timeout=None):
raise urllib.error.HTTPError(requete.full_url, 500, "boom", {}, io.BytesIO(b""))
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _erreur)
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_consultation_hub_injoignable_inconnu(monkeypatch):
def _panne(requete, timeout=None):
raise urllib.error.URLError("injoignable")
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _panne)
assert hub_roles.role_pour("admin-1", "c=1") is None
def test_url_hub_derivee_par_hote_soeur(monkeypatch):
# Sans variable explicite : dérivation depuis l'hôte de l'agent
# (agent-dev → hub-dev), comme la barre commune (#223).
monkeypatch.delenv("DRACO_HUB_URL")
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert appels[0][0] == "https://hub-dev.dracodev.net/api/roles/obsigate"
def test_url_hub_defaut_officiel(monkeypatch):
monkeypatch.delenv("DRACO_HUB_URL")
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
hub_roles.role_pour("admin-1", "c=1")
assert appels[0][0] == "https://hub.dracodev.net/api/roles/obsigate"
# ── Client : cache 60 s par sub, horodatage avant l'appel ──────────────
def test_cache_par_sub_et_ttl(monkeypatch):
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert len(appels) == 1 # 2e lecture : cache
assert hub_roles.role_pour("user-1", "c=2") == "utilisateur"
assert len(appels) == 2 # autre sub : nouvel appel
# TTL expiré → nouvel appel.
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
assert len(appels) == 3
def test_echec_conserve_ancien_cache_sans_rafale(monkeypatch):
appels = []
_patch_hub(monkeypatch, "administrateur", appels)
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
assert len(appels) == 1
# Le Hub tombe ; le TTL expire : un appel est tenté, échoue, et
# l'horodatage posé avant l'appel empêche la rafale — l'ancien
# rôle connu n'est ni effacé ni réinterrogé aussitôt.
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
_patch_hub(monkeypatch, None, appels)
assert hub_roles.role_pour("user-1", "c=2") is None
assert len(appels) == 2
# Dans la fenêtre qui suit l'échec, l'ancien cache sert toujours.
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
assert len(appels) == 2
def test_sub_vide_aucun_appel(monkeypatch):
appels = []
_patch_hub(monkeypatch, "utilisateur", appels)
assert hub_roles.role_pour("", "c=1") is None
assert appels == []
# ── Intégration : chemin draco (admin_client, secret draco posé) ──────
@pytest.fixture()
def _draco_env(monkeypatch, tmp_path):
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
monkeypatch.setattr(draco_session, "REVOKED_FILE",
tmp_path / "draco_revoked.json")
monkeypatch.setattr(draco_session, "ISSUED_FILE",
tmp_path / "draco_issued.json")
draco_session._revoked = None
draco_session._issued = None
yield
draco_session._revoked = None
draco_session._issued = None
def _pose_cookie_draco(client, user):
token, _jti, _exp = draco_session.issue_token(user)
client.cookies.set("draco_session", token)
return token
def test_draco_aucun_refuse_session_explicite(admin_client, _draco_env, monkeypatch):
appels = []
_patch_hub(monkeypatch, "aucun", appels)
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/me")
assert r.status_code == 403
assert "niveau « aucun »" in r.json()["detail"]
assert "registre des rôles du Hub" in r.json()["detail"]
assert appels and appels[0][1].find("draco_session=") >= 0 # Cookie relayé
def test_draco_aucun_mais_connexion_native_intacte(admin_client, _draco_env, monkeypatch):
# L'authentification native (émettrice, accès de secours) n'est
# jamais touchée par le registre : le login par mot de passe du
# même compte passe, même avec un niveau « aucun » au Hub.
_patch_hub(monkeypatch, "aucun", [])
r = admin_client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert r.status_code == 200, r.text
# Et la session native ainsi établie ouvre /me (canal natif, le
# rôle du Hub ne s'applique pas).
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
def test_draco_role_inconnu_comportement_actuel(admin_client, _draco_env, monkeypatch):
_patch_hub(monkeypatch, None, []) # Hub injoignable
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
# Canal draco + rôle inconnu : les routes admin gardent les
# règles actuelles (le rôle local admin suffit).
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 200, r.text
def test_draco_utilisateur_admin_local_refuse_admin(admin_client, _draco_env, monkeypatch):
# Compte local admin authentifié par draco, mais niveau Hub
# « utilisateur » : /api/auth/admin/* exige « administrateur ».
_patch_hub(monkeypatch, "utilisateur", [])
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text # session draco OK
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 403
assert "administrateur" in r.json()["detail"]
def test_draco_administrateur_hub_admin_ok(admin_client, _draco_env, monkeypatch):
_patch_hub(monkeypatch, "administrateur", [])
_pose_cookie_draco(admin_client, USER)
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 200, r.text
assert any(u["username"] == "admin" for u in r.json())
def test_draco_utilisateur_simple_admin_refuse_comme_avant(admin_client, _draco_env, monkeypatch):
# Compte local non admin, niveau Hub « administrateur » : le
# registre n'élève aucun privilège local — refus par la règle
# actuelle (rôle local), comme avant HUB-19.
_patch_hub(monkeypatch, "administrateur", [])
_pose_cookie_draco(admin_client, USER_SIMPLE)
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 403
assert r.json()["detail"] == "Accès admin requis"
def test_natif_admin_sans_draco_jamais_bloque_par_hub(admin_client, _draco_env, monkeypatch):
# Session native (login) + niveau Hub « utilisateur » : le canal
# n'est pas draco, la garde du registre ne s'applique pas.
_patch_hub(monkeypatch, "utilisateur", [])
r = admin_client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert r.status_code == 200, r.text
# Le jeton natif valide prime : get_current_user ne passe pas par
# le repli draco, le dict utilisateur n'a ni _draco_sub ni
# _hub_role — la garde du registre ne peut pas s'appliquer.
r = admin_client.get("/api/auth/admin/users")
assert r.status_code == 200, r.text