From f6476c786d75c878eb455f8701eb5a358a780b32 Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 6 Nov 2026 13:54:11 +0000 Subject: [PATCH] Kanbante 0.46.0 : pont SSO par utilisateur (fin du mono-utilisateur), /api/search par utilisateur, liaison draco par l'administrateur (app/main.py, app/draco.py) --- app/draco.py | 9 ++-- app/main.py | 146 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 103 insertions(+), 52 deletions(-) diff --git a/app/draco.py b/app/draco.py index 5ede1d1..3325a14 100644 --- a/app/draco.py +++ b/app/draco.py @@ -9,9 +9,12 @@ est présenté). Vérification hmac/hashlib — mêmes sémantiques que les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0). Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide -en session Kanbante native. Kanbante est mono-utilisateur -(identifiants configurés par l'environnement) : un jeton valide -désigne l'opérateur configuré — pas de table de liens. Sans secret +en session Kanbante native. Depuis la 0.46.0 (HUB-16), la +correspondance est PAR UTILISATEUR : le ``sub`` des claims désigne +le compte Kanbante lié dans ``users.draco_sub`` (résolution et +création JIT : app.accounts.resolve_draco_user) — un jeton valide +ne désigne plus « l'opérateur » par hypothèse. Ce module ne fait +que valider le jeton ; il ne connaît aucun compte. Sans secret configuré, module inerte. """ diff --git a/app/main.py b/app/main.py index 57d47c0..4d7fbb3 100644 --- a/app/main.py +++ b/app/main.py @@ -391,15 +391,24 @@ class AuthMiddleware(BaseHTTPMiddleware): and _bearer_user(request) is not None: return await call_next(request) # Recherche partout (#68) : /api/search accepte aussi le jeton - # SSO draco_session (mono-utilisateur : un jeton valide désigne - # l'opérateur), comme les API de recherche des sœurs. Les - # preflights OPTIONS ne portent jamais de cookies : ils sont - # répondus par RechercheCorsMiddleware avant d'arriver ici. + # SSO draco_session — depuis la 0.46.0 (HUB-16), par + # utilisateur : le sub doit désigner un compte Kanbante + # (correspondance existante ou compte d'amorçage lié au + # passage, voir app.accounts.resolve_draco_user) ; un jeton + # valide sans correspondance ne désigne plus l'opérateur et + # ne suffit plus. Aucun JIT ici : la création du compte a + # lieu à la première connexion par le pont /auth/draco. + # Les preflights OPTIONS ne portent jamais de cookies : ils + # sont répondus par RechercheCorsMiddleware avant d'arriver ici. if path == "/api/search": try: + from app import accounts as accounts_service from app import draco - if draco.claims_for(request) is not None: + claims = draco.claims_for(request) + if claims is not None and ( + accounts_service.resolve_draco_user(claims) is not None + ): return await call_next(request) except Exception: pass @@ -571,67 +580,50 @@ def draco_bridge(request: Request, next: str = "/"): """Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session. Point de retour de ``GET /sso/authorize`` de l'émettrice - (ObsiGate). Depuis 0.24.0 la liaison est PAR UTILISATEUR : - si le ``sub`` draco est lié à un compte, sa session est - créée ; si une session native est déjà ouverte, le ``sub`` - est lié à ce compte (première visite doublement connectée) ; - sans compte lié, retour au login — la connexion native qui - suit crée le lien (voir login_submit). Sans utilisateurs en - base, comportement historique : session de l'opérateur. + (ObsiGate). Depuis la 0.46.0 (HUB-16), la correspondance est + PAR UTILISATEUR et l'hypothèse mono-utilisateur a disparu — + résolution centralisée dans app.accounts.resolve_draco_user : + + - session native déjà ouverte : le ``sub`` libre est lié à + ce compte (première visite doublement connectée, inchangé) ; + - ``sub`` lié à un compte : sa session est ouverte ; + - compte d'amorçage (unique compte, admin, sans liaison) : + liaison au premier passage, puis sa session — le compte + existant de l'opérateur garde son accès ; + - sinon : création JIT d'un compte propre à cette identité + (rôle membre, mot de passe local aléatoire non divulgué) + et ouverture de sa session. + + 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``. """ + from app import accounts as accounts_service from app import draco settings = get_settings() claims = draco.claims_for(request) if not settings.auth_enabled or claims is None: return RedirectResponse("/login", status_code=303) - sub = claims.get("sub") + sub = str(claims.get("sub") or "") + if not sub: + return RedirectResponse("/login", status_code=303) # 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: - if sub and not current.get("draco_sub"): + if not current.get("draco_sub"): if models.get_user_by_draco(sub) is None: models.update_user(current["id"], draco_sub=sub) - models.log_admin(current["id"], "draco.link", str(sub)) + models.log_admin(current["id"], "draco.link", sub) return RedirectResponse(_safe_next(next), status_code=303) - # Compte lié à ce sub : ouvrir sa session. - linked = models.get_user_by_draco(sub) if sub else None - if linked is not None and linked["active"]: + # Correspondance, amorçage ou création JIT : ouvrir la + # session du compte que ce sub désigne — et seulement lui. + 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(linked["username"]), - max_age=SESSION_DAYS * 86400, - httponly=True, - samesite="lax", - ) - return response - if models.count_users() == 1: - # Mono-utilisateur (cas historique) : le premier sub - # valide se lie au compte unique et ouvre sa session — - # c'est la sémantique livrée en 0.23.0, conservée tant - # qu'il n'y a qu'un seul compte. - sole = models.list_users()[0] - if sole["active"]: - if sub and not sole.get("draco_sub"): - if models.get_user_by_draco(sub) is None: - models.update_user(sole["id"], draco_sub=sub) - response = RedirectResponse(_safe_next(next), status_code=303) - response.set_cookie( - SESSION_COOKIE, - _make_session(sole["username"]), - max_age=SESSION_DAYS * 86400, - httponly=True, - samesite="lax", - ) - return response - if models.count_users() == 0: - # Mode env pur (aucun compte) : session de l'opérateur. - response = RedirectResponse(_safe_next(next), status_code=303) - response.set_cookie( - SESSION_COOKIE, - _make_session(settings.auth_user), + _make_session(user["username"]), max_age=SESSION_DAYS * 86400, httponly=True, samesite="lax", @@ -1164,6 +1156,62 @@ async def admin_reset_user( return RedirectResponse("/admin?msg=Mot+de+passe+r%C3%A9initialis%C3%A9", status_code=303) +@app.post("/admin/users/{user_id}/draco") +async def admin_draco_user( + request: Request, + user_id: int, + draco_sub: str = Form(""), + csrf_token: str = Form(""), +): + """Liaison draco par l'administrateur (HUB-16, 0.46.0). + + C'est le mécanisme de liaison des comptes Kanbante + préexistants : l'administrateur saisit le ``sub`` draco du + compte (colonne Draco de /admin) ; un champ vide délie. + Un ``sub`` déjà lié à un autre compte est refusé (la + correspondance est biunivoque : users.draco_sub est UNIQUE). + Contrairement au rôle et à l'activation, le geste est permis + sur son propre compte : c'est la voie manuelle qui établit la + correspondance du compte d'amorçage si le premier passage ne + l'a pas fait. Le registre matriciel côté Hub (HUB-19) lira + cette même correspondance ; ici, seul le côté Kanbante est + requis. Procédure : docs/correspondance-draco.md. + """ + from urllib.parse import quote as _q + + admin = _require_admin(request) + if admin is None: + return _admin_denied(request) + if not _csrf_valid(request, csrf_token): + return _csrf_error() + target = models.get_user(user_id) + if target is None: + return RedirectResponse("/admin", status_code=303) + sub = draco_sub.strip() + if sub: + autre = models.get_user_by_draco(sub) + if autre is not None and autre["id"] != user_id: + return RedirectResponse( + "/admin?err=" + + _q("Identité draco déjà liée à un autre compte"), + status_code=303, + ) + models.update_user(user_id, draco_sub=sub) + models.log_admin( + admin.get("id"), "draco.link.admin", + f"{target['username']} : {sub}", + ) + return RedirectResponse( + "/admin?msg=" + _q("Liaison draco enregistrée"), + status_code=303, + ) + models.update_user(user_id, draco_sub=None) + models.log_admin(admin.get("id"), "draco.unlink.admin", target["username"]) + return RedirectResponse( + "/admin?msg=" + _q("Liaison draco retirée"), status_code=303 + ) + + @app.get("/admin/connections") async def admin_connections(request: Request, msg: str = "", err: str = ""): admin = _require_admin(request)