# 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