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
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:
+33
-1
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user