feat: HUB-19 — rôles du Hub consommés par ObsiGate (registre D3(a)) #228
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user