127 lines
4.5 KiB
Python
127 lines
4.5 KiB
Python
# 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
|