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
+1 -1
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.85.1"
# version="7.85.2"
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
+10
View File
@@ -11,6 +11,16 @@ async def admin_required(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=403, detail="Admin access required")
# HUB-19 (registre des rôles du Hub) : session draco + rôle connu
# pour Flowdeck → exiger « administrateur » dans le registre,
# en plus du drapeau local. Pas de session draco ou rôle inconnu
# (Hub injoignable) : comportement actuel.
from app.services import hub_roles
if not hub_roles.admin_hub_ok(request):
raise HTTPException(
status_code=403,
detail="Niveau « administrateur » requis dans le registre "
"des rôles du Hub pour FlowDeck")
# Also check DB directly (session cookie may be stale)
if not user.get("is_admin"):
from app.db import get_conn
+8
View File
@@ -127,6 +127,14 @@ def _current_admin(request: Request) -> dict:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
# dans le registre du Hub (voir app.services.hub_roles.admin_hub_ok).
from app.services import hub_roles
if not hub_roles.admin_hub_ok(request):
raise HTTPException(
status_code=403,
detail="Niveau « administrateur » requis dans le registre "
"des rôles du Hub pour FlowDeck")
return user
+8
View File
@@ -27,6 +27,14 @@ def _admin_user(request: Request) -> dict:
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
(sess.get("id"),)).fetchone()
if row and row["is_admin"]:
# HUB-19 : session draco + rôle Hub connu → exiger
# « administrateur » dans le registre du Hub (la voie
# Bearer ci-dessous, contrat d'API propre, est exemptée).
from app.services import hub_roles
if not hub_roles.admin_hub_ok(request):
raise HTTPException(
403, "Niveau « administrateur » requis dans le "
"registre des rôles du Hub pour FlowDeck")
return sess
raise HTTPException(403, "Admin required")
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
+30 -1
View File
@@ -483,10 +483,39 @@ def draco_bridge(request: Request, next: str = "/workspaces"):
Point de retour de ``GET /sso/authorize`` de l'émettrice : si le
cookie est valide et résolu vers un compte local, une session
Flowdeck native est créée ; sinon, retour à la connexion locale.
HUB-19 (registre des rôles du Hub, contrat D3(a)) : rôle connu
``aucun`` pour Flowdeck → refus 403 en français nommant le
registre du Hub, **avant** la création de la session locale et
avant tout provisionnement JIT (aucun compte créé pour un
``sub`` refusé). Exemption, patron du pilote ShaarIt : une
session locale préexistante (accès de secours) n'est pas
refusée — le pont la laisse simplement se poursuivre. Rôle
inconnu (Hub injoignable/erreur) : comportement actuel.
"""
from fastapi.responses import HTMLResponse
from app.services import draco
user = draco.user_for_draco(request)
_cookie_session = request.cookies.get("flowdeck_session", "")
session_existante = (SessionManager.decode_session(_cookie_session)
if _cookie_session else None)
try:
user = draco.user_for_draco(
request, refuser_aucun=not session_existante)
except draco.RoleAucunRefuse:
return HTMLResponse(
"""<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8">
<title>Accès refusé — FlowDeck</title></head><body><main>
<h1>Accès refusé</h1>
<p>Le registre des rôles de la suite (Hub Dracodev) attribue le niveau
« aucun » à votre compte pour FlowDeck : l'accès par la session draco
est refusé. L'administrateur de suite peut modifier ce niveau dans la
matrice des rôles du Hub. L'accès de secours par le mot de passe local
de l'instance reste possible par la page de connexion.</p>
</main></body></html>""",
status_code=403,
)
if user is None:
return RedirectResponse(url="/auth/login?provider=local", status_code=302)
session = SessionManager.create_session(user, request)
+8
View File
@@ -46,6 +46,14 @@ def _admin_session(request: Request) -> dict:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(403, "Admin required")
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
# dans le registre du Hub (les jetons SCIM Bearer, contrat propre,
# passent par _scim_guard et sont exemptés).
from app.services import hub_roles
if not hub_roles.admin_hub_ok(request):
raise HTTPException(
403, "Niveau « administrateur » requis dans le registre "
"des rôles du Hub pour FlowDeck")
return user
+9
View File
@@ -527,6 +527,15 @@ def _require_admin(request: Request, *, write: bool) -> dict:
).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Admin access required")
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
# dans le registre du Hub (la voie Bearer ci-dessus, contrat d'API
# propre, est exemptée).
from app.services import hub_roles
if not hub_roles.admin_hub_ok(request):
raise HTTPException(
status_code=403,
detail="Niveau « administrateur » requis dans le registre "
"des rôles du Hub pour FlowDeck")
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
cookie = request.cookies.get("csrf_token", "")
header = request.headers.get("X-CSRF-Token", "")
+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"