Files
kanbante/app/hub_roles.py
T

121 lines
4.1 KiB
Python

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