From e024bd1a97ac1de7154627197e2e447c04c38f9d Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 6 Nov 2026 10:53:37 -0500 Subject: [PATCH] =?UTF-8?q?Kanbante=200.46.2=20:=20client=20du=20registre?= =?UTF-8?q?=20des=20r=C3=B4les=20du=20Hub=20(contrat=20D3(a),=20cache=2060?= =?UTF-8?q?=20s=20par=20sub,=20repli=20r=C3=B4le=20inconnu)=20(app/hub=5Fr?= =?UTF-8?q?oles.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/hub_roles.py | 120 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 app/hub_roles.py 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