"""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