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
156 lines
5.5 KiB
Python
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"
|