From bd8af99652c2a12f5754e5ef558554b3fd21e1ad Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 6 Nov 2026 10:53:48 -0500 Subject: [PATCH] =?UTF-8?q?Kanbante=200.46.2=20:=20pont=20draco=20?= =?UTF-8?q?=E2=80=94=20consultation=20du=20Hub=20avant=20r=C3=A9solution/J?= =?UTF-8?q?IT,=20refus=20=C2=AB=20aucun=20=C2=BB=20sans=20session=20ni=20c?= =?UTF-8?q?ompte,=20marqueur=20de=20session=20draco=20sign=C3=A9,=20/admin?= =?UTF-8?q?=20gard=C3=A9=20par=20le=20registre=20pour=20les=20sessions=20d?= =?UTF-8?q?raco=20(app/main.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 116 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/app/main.py b/app/main.py index 4d7fbb3..b795f10 100644 --- a/app/main.py +++ b/app/main.py @@ -263,23 +263,48 @@ def _session_secret() -> bytes: -def _make_session(user: str) -> str: - """Signed session cookie (0.22.0): user.expiry.hmac.""" +def _make_session(user: str, via_draco: bool = False) -> str: + """Signed session cookie (0.22.0): user.expiry.hmac. + + HUB-19 (0.46.2) : une session **établie par le pont draco** + porte un segment ``draco`` couvert par la signature + (``user.expiry.draco.hmac``) — c'est ce marqueur infalsifiable + qui soumet ensuite ``/admin`` au registre des rôles du Hub + (voir ``_require_admin``). Les sessions natives gardent le + format historique, et les deux formats restent lisibles. + """ exp = int(time.time()) + SESSION_DAYS * 86400 + charge = f"{user}.{exp}.draco" if via_draco else f"{user}.{exp}" sig = hmac.new( - _session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256 + _session_secret(), charge.encode("utf-8"), hashlib.sha256 ).hexdigest() - return f"{user}.{exp}.{sig}" + return f"{charge}.{sig}" -def _session_user(request: Request) -> str | None: +def _session_info(request: Request) -> tuple[str, bool] | None: + """(utilisateur, établie par draco) de la session, ou None. + + Vérifie signature et expiration, dans les deux formats + (natif historique et marqué draco de la 0.46.2). + """ raw = request.cookies.get(SESSION_COOKIE, "") - user, _, rest = raw.partition(".") - exp, _, sig = rest.partition(".") + morceaux = raw.split(".") + if len(morceaux) < 3: + return None + sig = morceaux[-1] + via_draco = len(morceaux) >= 4 and morceaux[-2] == "draco" + if via_draco: + exp = morceaux[-3] + user = ".".join(morceaux[:-3]) + charge = f"{user}.{exp}.draco" + else: + exp = morceaux[-2] + user = ".".join(morceaux[:-2]) + charge = f"{user}.{exp}" if not user or not exp or not sig: return None expected = hmac.new( - _session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256 + _session_secret(), charge.encode("utf-8"), hashlib.sha256 ).hexdigest() if not secrets.compare_digest(sig, expected): return None @@ -288,6 +313,14 @@ def _session_user(request: Request) -> str | None: return None except ValueError: return None + return user, via_draco + + +def _session_user(request: Request) -> str | None: + info = _session_info(request) + if info is None: + return None + user = info[0] # 0.24.0 : la session peut appartenir à n'importe quel # compte de la base (résolu ensuite par _current_user) ou, # en mode env pur, à l'opérateur d'environnement. @@ -299,6 +332,12 @@ def _session_user(request: Request) -> str | None: return None +def _session_via_draco(request: Request) -> bool: + """Vrai si la session présentée a été établie par le pont draco.""" + info = _session_info(request) + return bool(info and info[1] and _session_user(request) is not None) + + def _basic_ok(request: Request) -> bool: settings = get_settings() header = request.headers.get("authorization", "") @@ -597,9 +636,21 @@ def draco_bridge(request: Request, next: str = "/"): Un ``sub`` inconnu n'ouvre plus jamais la session de l'opérateur d'environnement ni celle d'un compte non lié. Inerte sans ``DRACO_SESSION_SECRET``. + + HUB-19 (0.46.2, contrat D3(a)) : le registre des rôles du Hub + est consulté **en premier** — avant la liaison à une session + native, avant la résolution de la correspondance et avant + toute création JIT. Rôle connu ``aucun`` → le pont refuse : + aucune session Kanbante n'est ouverte par draco pour ce + ``sub`` et aucun compte n'est créé. Rôle inconnu (Hub + injoignable) → comportement actuel, inchangé. Les autres + accès ne passent pas par ce pont et ne sont pas touchés : + session native préexistante, connexion native (mot de + passe), Basic d'environnement, jetons ``kbt_``. """ from app import accounts as accounts_service from app import draco + from app import hub_roles settings = get_settings() claims = draco.claims_for(request) @@ -608,6 +659,28 @@ def draco_bridge(request: Request, next: str = "/"): sub = str(claims.get("sub") or "") if not sub: return RedirectResponse("/login", status_code=303) + # HUB-19 : consultation du registre AVANT toute résolution + # ou création (resolve_draco_user peut créer le compte JIT : + # un sub refusé ne doit jamais en recevoir un). + role_hub = hub_roles.role_pour( + request.headers.get("cookie", ""), sub, + hote=request.headers.get("host"), + ) + if role_hub == "aucun": + return render( + request, "error.html", status_code=403, + message=( + "Accès refusé : le registre des rôles de la suite " + "(Hub Dracodev) attribue le niveau « aucun » à " + "votre compte pour Kanbante — l'ouverture de " + "session par draco est refusée et aucun compte " + "Kanbante n'a été créé. L'administrateur de la " + "suite peut modifier ce niveau dans la matrice " + "des rôles du Hub. Vos autres accès à Kanbante " + "(connexion par mot de passe, session déjà " + "ouverte) ne sont pas touchés." + ), + ) # Déjà connecté nativement : lier le sub à ce compte. current = _current_user(request) if current is not None and current.get("id") is not None: @@ -618,12 +691,15 @@ def draco_bridge(request: Request, next: str = "/"): return RedirectResponse(_safe_next(next), status_code=303) # Correspondance, amorçage ou création JIT : ouvrir la # session du compte que ce sub désigne — et seulement lui. + # La session est marquée « établie par draco » (segment signé, + # voir _make_session) : c'est elle, et elle seule, que le + # garde de /admin soumettra au registre du Hub. user = accounts_service.resolve_draco_user(claims, provisionner=True) if user is not None: response = RedirectResponse(_safe_next(next), status_code=303) response.set_cookie( SESSION_COOKIE, - _make_session(user["username"]), + _make_session(user["username"], via_draco=True), max_age=SESSION_DAYS * 86400, httponly=True, samesite="lax", @@ -1006,6 +1082,28 @@ def _require_admin(request: Request) -> dict | None: user = _current_user(request) if user is None or user.get("role") != "admin": return None + # HUB-19 (0.46.2) : pour une session **établie par le pont + # draco** (marqueur signé, voir _make_session), si le rôle du + # registre du Hub est connu, il faut AUSSI « administrateur » + # dans le registre — en plus du rôle local ci-dessus. Rôle + # inconnu (Hub injoignable) ou session native (mot de passe, + # Basic, jeton) : règles locales actuelles, inchangées. Ce + # garde couvre /admin et toutes ses actions (même fonction). + if _session_via_draco(request): + from app import draco + from app import hub_roles + + sub = str(user.get("draco_sub") or "") + if not sub: + claims = draco.claims_for(request) + sub = str((claims or {}).get("sub") or "") + if sub: + role_hub = hub_roles.role_pour( + request.headers.get("cookie", ""), sub, + hote=request.headers.get("host"), + ) + if role_hub is not None and role_hub != "administrateur": + return None return user