Files
flowdeck/app/services/hub_roles.py
Spark bd5357f589
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 12s
FlowDeck CI / test (pull_request) Failing after 2s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 14s
Flowdeck 7.85.2 — HUB-19 : propagation des rôles du Hub (registre D3(a))
2026-11-06 15:36:56 +00:00

156 lines
5.5 KiB
Python

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