"""Rôles par application — consommation du registre du Hub (HUB-19). Propagation du pilote ShaarIt (0.7.0, en production) à Flowdeck, sur le contrat D3(a) du plan Hub v2 : à l'ouverture de session draco, Flowdeck demande au Hub le niveau du porteur du cookie pour l'application ``flowdeck`` (``GET {hub}/api/roles/flowdeck``, en-tête Cookie complet de la requête relayé tel quel, ``Accept: application/json``, délai 2 s) et met la réponse en cache 60 s par ``sub`` — patron du client Rust ``hub_roles.rs`` de ShaarIt et du cache de révocations de ``app.services.draco`` (horodatage posé **avant** l'appel : un échec ne déclenche pas de rafale, l'ancien cache reste en vigueur). Réponse attendue (200) : ``{"app", "sub", "role", "explicite", "ttl_secondes": 60}``, rôle ∈ {``aucun``, ``utilisateur``, ``administrateur``}. **Repli obligatoire** : Hub injoignable, erreur (401/404/…), réseau, réponse illisible ou rôle hors ensemble → rôle **inconnu** (None), et l'appelant garde son comportement actuel. Le registre ne crée jamais un verrouillage nouveau. L'URL du Hub est résolue comme la barre commune de Flowdeck (``app.services.draco_urls.hub_url()`` : variable explicite ``HUB_URL`` / réglage, dérivation par l'hôte de la requête, puis par l'hôte sœur de l'agent, puis défaut officiel) — jamais d'URL dev figée. """ from __future__ import annotations import logging import time logger = logging.getLogger(__name__) #: Identifiant de Flowdeck dans le registre des applications du Hub. APP = "flowdeck" #: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub et #: sur le cache de révocations draco. CACHE_TTL = 60 # secondes ROLES_CONNUS = ("aucun", "utilisateur", "administrateur") # Cache mémoire par sub : sub -> rôle connu (str) ou None (échec déjà # tenté dans la fenêtre — l'ancien résultat reste servi, voir # ``role_pour``). ``_cache_fetched_at`` est l'horodatage global du # dernier appel, posé avant l'appel (patron révocations). _cache: dict[str, str | None] = {} _cache_fetched_at: float = 0.0 def _consulter_hub(cookie: str) -> str | None: """Un appel au Hub ; retourne le rôle validé ou None (inconnu).""" from app.services import draco_urls base = draco_urls.hub_url() url = f"{base}/api/roles/{APP}" try: import httpx r = httpx.get( url, headers={"Cookie": cookie, "Accept": "application/json"}, timeout=2.0, ) if r.status_code != 200: return None role = r.json().get("role") except Exception as exc: # réseau, JSON illisible… → repli logger.debug("Registre des rôles du Hub indisponible (%s): %s", url, exc) return None if role not in ROLES_CONNUS: return None return role def role_pour(cookie: str, sub: str) -> str | None: """Niveau connu pour ce ``sub`` : ``aucun`` | ``utilisateur`` | ``administrateur`` si le Hub a répondu, None si le rôle est inconnu (Hub injoignable/erreur) — repli = comportement actuel. ``cookie`` est l'en-tête Cookie complet de la requête, relayé tel quel au Hub ; ``sub`` est le sujet draco prouvé localement. Cache mémoire par ``sub``, TTL 60 s, horodatage posé avant l'appel : pendant la fenêtre qui suit un échec, l'ancien cache reste en vigueur (pas de rafale vers le Hub). """ global _cache_fetched_at if not sub: return None now = time.monotonic() if now - _cache_fetched_at < CACHE_TTL and sub in _cache: return _cache[sub] # Horodatage posé AVANT l'appel (patron révocations du pilote). ancien = _cache.get(sub) _cache_fetched_at = now resultat = _consulter_hub(cookie) if resultat is not None: _cache[sub] = resultat return resultat # Échec : l'ancien cache reste en vigueur. logger.debug( "Registre des rôles du Hub indisponible — comportement actuel conservé") return ancien def role_draco(request) -> str | None: """Rôle du porteur draco de cette requête, ou None (inconnu). None aussi quand il n'y a pas de session draco prouvée : pas de cookie ``draco_session``, jeton invalide/expiré/révoqué, ou SSO draco non configuré — l'appelant garde son comportement actuel. Le ``sub`` vient de la vérification locale du jeton (mêmes sémantiques que ``user_for_draco``, sans résolution de compte ni provisionnement). """ from app.services import draco if not draco.enabled(): return None claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, "")) if not claims: return None jti = str(claims.get("jti", "")) if jti and draco.is_revoked(jti): return None sub = str(claims.get("sub", "")) if not sub: return None # En-tête Cookie complet de la requête, relayé tel quel au Hub. cookie = request.headers.get("cookie", "") return role_pour(cookie, sub) def admin_hub_ok(request) -> bool: """Vrai si les surfaces d'administration restent ouvertes. Session draco + rôle connu → exiger ``administrateur``. Pas de session draco (session locale par mot de passe, jeton d'API propre à Flowdeck, OAuth externe) ou rôle inconnu (Hub injoignable) : comportement actuel (True). """ from app.services import draco if not draco.enabled(): return True claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, "")) if not claims: return True role = role_draco(request) if role is None: return True return role == "administrateur"