diff --git a/app/hub_roles.py b/app/hub_roles.py new file mode 100644 index 0000000..8a60760 --- /dev/null +++ b/app/hub_roles.py @@ -0,0 +1,120 @@ +"""Rôles par application — consommation du registre du Hub (HUB-19). + +Portage Python du pilote ShaarIt 0.7.0 (``src/hub_roles.rs``), +contrat D3(a) du plan Hub v2 : à l'ouverture de session draco, +Kanbante demande au Hub le niveau du porteur du cookie pour +l'application ``kanbante`` — ``GET {hub}/api/roles/kanbante``, +en-tête Cookie complet de la requête relayé tel quel, +``Accept: application/json``, délai 2 s → ``{"app", "sub", +"role", "explicite", "ttl_secondes": 60}``, rôle ∈ {``aucun``, +``utilisateur``, ``administrateur``}. + +Cache mémoire par ``sub``, TTL 60 s, sur le patron du cache de +révocations de ``app.draco`` : l'horodatage est posé **avant** +l'appel — un échec ne déclenche pas de rafale et l'ancien cache +reste en vigueur (il resservira aux appels suivants pendant le +TTL posé par la tentative). L'URL du Hub est résolue comme celle +de la barre commune (``app.config.resoudre_urls_draco`` : +variable explicite ``DRACO_HUB_URL`` > hôte public de la requête +> dérivation par l'hôte sœur de l'agent > défaut officiel) — +jamais d'URL dev figée. + +**Repli obligatoire** : Hub injoignable, en erreur ou réponse +illisible → ``None`` (rôle **inconnu**), et l'appelant garde son +comportement actuel. Le registre ne crée jamais un verrouillage +nouveau. +""" + +from __future__ import annotations + +import logging +import threading +import time + +import httpx + +from app.config import resoudre_urls_draco + +logger = logging.getLogger(__name__) + +#: Identifiant de Kanbante dans le registre des applications du Hub. +APP = "kanbante" + +#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub +#: et sur le cache de révocations draco. +CACHE_TTL = 60.0 # secondes + +ROLES_CONNUS = ("aucun", "utilisateur", "administrateur") + +_verrou = threading.Lock() +_cache: dict[str, str] = {} +_cache_fetched_at: float = 0.0 + + +def _reset_cache() -> None: + """Vide le cache (tests, ou redémarrage de processus).""" + global _cache_fetched_at + with _verrou: + _cache.clear() + _cache_fetched_at = 0.0 + + +def _consulter(hub_url: str, cookie: str) -> str | None: + """Un appel au registre ; rôle connu ou ``None`` (inconnu).""" + url = f"{hub_url.rstrip('/')}/api/roles/{APP}" + try: + reponse = httpx.get( + url, + headers={"Cookie": cookie, "Accept": "application/json"}, + timeout=2.0, + ) + except Exception as exc: # Hub injoignable : repli, jamais un blocage + logger.debug("Registre des rôles du Hub injoignable (%s) : %s", url, exc) + return None + if getattr(reponse, "status_code", None) != 200: + logger.debug( + "Registre des rôles du Hub en erreur (%s) : statut %s", + url, getattr(reponse, "status_code", "?"), + ) + return None + try: + donnees = reponse.json() + except Exception: + return None + if not isinstance(donnees, dict): + return None + role = donnees.get("role") + return role if role in ROLES_CONNUS else None + + +def role_pour(cookie: str, sub: str, hote: str | None = None) -> str | None: + """Niveau connu pour ce ``sub`` : un rôle ou ``None`` (inconnu). + + ``cookie`` est l'en-tête Cookie complet de la requête, relayé + tel quel au Hub ; ``hote`` est l'hôte public de la requête, + pour résoudre l'URL du Hub comme la barre commune. + """ + global _cache_fetched_at + sub = str(sub or "") + if not sub: + return None + now = time.monotonic() + with _verrou: + if now - _cache_fetched_at < CACHE_TTL and sub in _cache: + return _cache[sub] + # Horodatage posé avant l'appel (patron révocations) : + # l'ancien cache est conservé tel quel pendant l'appel. + _cache_fetched_at = now + hub_url = resoudre_urls_draco(hote).hub_url + resultat = _consulter(hub_url, cookie) + if resultat is not None: + with _verrou: + _cache[sub] = resultat + _cache_fetched_at = time.monotonic() + else: + logger.debug( + "Registre des rôles du Hub indisponible (%s/api/roles/%s)" + " — comportement actuel conservé", + hub_url, APP, + ) + return resultat