Kanbante 0.46.2 : pont draco — consultation du Hub avant résolution/JIT, refus « aucun » sans session ni compte, marqueur de session draco signé, /admin gardé par le registre pour les sessions draco (app/main.py)
CI / test (pull_request) Failing after 0s
CI / test (push) Failing after 3m12s

This commit is contained in:
2026-11-06 10:53:48 -05:00
parent 00920e8e02
commit bd8af99652
+107 -9
View File
@@ -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