Flowdeck 7.85.2 — HUB-19 : propagation des rôles du Hub (registre D3(a))
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

This commit is contained in:
Spark
2026-11-06 15:36:56 +00:00
parent 5203b6288e
commit bd5357f589
12 changed files with 579 additions and 4 deletions
+33 -1
View File
@@ -26,6 +26,12 @@ admin), aucun mot de passe local (``password_hash`` NULL,
connexion locale par mot de passe reste impossible pour ce compte
et inchangée pour les autres). Une pierre tombale (dissociation
explicite) interdit toute re-création automatique pour ce ``sub``.
HUB-19 (registre des rôles du Hub) : le pont d'ouverture de session
peut demander le refus d'un ``sub`` au rôle connu ``aucun``
(``user_for_draco(..., refuser_aucun=True)`` →
``RoleAucunRefuse``), levé avant la résolution de compte et le
provisionnement JIT — voir ``app.services.hub_roles``.
"""
from __future__ import annotations
@@ -45,6 +51,16 @@ logger = logging.getLogger(__name__)
COOKIE_NAME = "draco_session"
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
class RoleAucunRefuse(Exception):
"""HUB-19 : le registre des rôles du Hub attribue le niveau
``aucun`` à ce ``sub`` pour Flowdeck — l'ouverture de session
draco est refusée (403), avant tout provisionnement JIT."""
def __init__(self, sub: str):
super().__init__(sub)
self.sub = sub
_revoked_cache: dict[str, int] = {}
_revoked_fetched_at: float = 0.0
@@ -215,12 +231,22 @@ def _provision_jit(conn, claims: dict) -> dict | None:
return dict(row) if row is not None else None
def user_for_draco(request) -> dict | None:
def user_for_draco(request, refuser_aucun: bool = False) -> dict | None:
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou None.
Résolution : lien ``draco_links`` existant, sinon repli
mono-utilisateur, sinon provisionnement JIT (HUB-15) — le
pendant local est créé depuis les claims et lié au ``sub``.
HUB-19 (registre des rôles du Hub, contrat D3(a)) : avec
``refuser_aucun=True`` — le pont d'ouverture de session
``GET /auth/draco``, seul appelant qui crée une session locale —
un rôle **connu** ``aucun`` dans le registre du Hub lève
``RoleAucunRefuse`` **avant** toute résolution de compte et donc
avant tout provisionnement JIT : aucun compte n'est créé pour un
``sub`` refusé. Rôle inconnu (Hub injoignable/erreur) : repli,
comportement actuel inchangé. Les autres appelants (recherche
fédérée) gardent le défaut ``False`` : aucun refus nouveau.
"""
if not enabled():
return None
@@ -233,6 +259,12 @@ def user_for_draco(request) -> dict | None:
sub = str(claims.get("sub", ""))
if not sub:
return None
if refuser_aucun:
from app.services import hub_roles
if hub_roles.role_pour(
request.headers.get("cookie", ""), sub) == "aucun":
raise RoleAucunRefuse(sub)
with get_conn() as conn:
user = _linked_user(conn, sub)
if user is None:
+155
View File
@@ -0,0 +1,155 @@
"""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"