From 5f19488c472541b7bcf401d452a7af0a54480b6f Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 6 Nov 2026 13:54:11 +0000 Subject: [PATCH] =?UTF-8?q?Kanbante=200.46.0=20:=20correspondance=20draco?= =?UTF-8?q?=20=E2=80=94=20create=5Fuser(draco=5Fsub)=20et=20provisionnemen?= =?UTF-8?q?t=20(r=C3=A9solution,=20amor=C3=A7age=20unique=20admin,=20JIT?= =?UTF-8?q?=20membre)=20(app/models.py,=20app/accounts.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/accounts.py | 130 ++++++++++++++++++++++++++++++++++++++++++++++++ app/models.py | 6 ++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/app/accounts.py b/app/accounts.py index 74a2d6a..fd6bbab 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -9,7 +9,10 @@ be raised later without invalidating existing hashes. import base64 import hashlib import hmac +import re import secrets +import sqlite3 +import unicodedata from app import models @@ -66,3 +69,130 @@ def ensure_bootstrap_admin() -> dict | None: display_name=settings.auth_user, ) return models.get_user(user_id) + + +# ── Correspondance draco par utilisateur (HUB-16, 0.46.0) ──── +# La correspondance identité draco ↔ compte Kanbante vit dans la +# colonne users.draco_sub (UNIQUE, présente depuis la 0.24.0) : +# models.get_user_by_draco(sub) est la résolution canonique. +# resolve_draco_user() est l'unique point d'entrée du pont SSO +# (app.main.draco_bridge) et du contrôle draco de /api/search : +# +# 1. correspondance existante → son compte (None si désactivé) ; +# 2. amorçage : s'il n'existe qu'UN seul compte et que c'est un +# admin actif sans liaison, le premier sub valide s'y lie — +# le compte d'amorçage (celui de Bruno, créé depuis +# l'environnement) garde ainsi son accès sans verrouillage ; +# 3. sinon, si provisionner=True : création JIT d'un compte +# propre à cette identité (voir provision_draco_user). +# +# Un sub inconnu ne désigne JAMAIS un compte existant non lié +# (ni l'opérateur d'environnement, ni un compte dont le nom +# ressemblerait au nom affiché draco) : la liaison d'un compte +# préexistant est un geste d'administrateur (/admin, voir +# docs/correspondance-draco.md) ou la première visite +# doublement connectée (session native + cookie draco). + + +def _slug_draco(valeur: str) -> str: + texte = unicodedata.normalize("NFKD", valeur or "") + texte = texte.encode("ascii", "ignore").decode("ascii").lower() + return re.sub(r"[^a-z0-9]+", "-", texte).strip("-")[:40].strip("-") + + +def _nom_utilisateur_draco(nom_affiche: str, sub: str) -> str: + """Nom d'utilisateur libre dérivé des claims (jamais réutilisé). + + Base : le nom affiché slugifié (« Alice Exemple » → + « alice-exemple ») ; repli « draco- ». Si le nom + est déjà pris — y compris par un compte préexistant NON lié, + qui ne doit pas être adopté — un suffixe -2, -3… est ajouté : + le JIT crée toujours un compte nouveau, distinct. + """ + base = _slug_draco(nom_affiche) + if len(base) < 3: + base = _slug_draco(f"draco-{sub}") or "draco" + candidat, n = base, 2 + while models.get_user_by_username(candidat) is not None: + candidat = f"{base}-{n}" + n += 1 + return candidat + + +def provision_draco_user(sub: str, nom_affiche: str = "") -> dict | None: + """Création JIT du compte Kanbante d'une identité draco (HUB-16). + + Rôle par défaut sûr : membre (« member », l'utilisateur + standard de Kanbante) — jamais admin. Le mot de passe local + est un secret aléatoire haché, jamais divulgué : l'accès + normal passe par draco ; un administrateur peut poser un mot + de passe local depuis /admin si besoin. Le compte rejoint + l'espace Principal comme membre, comme tout nouvel + utilisateur créé par l'administration. + """ + sub = str(sub or "") + if not sub: + return None + existant = models.get_user_by_draco(sub) + if existant is not None: + return existant + nom = (nom_affiche or "").strip()[:80] + for _tentative in range(5): + username = _nom_utilisateur_draco(nom, sub) + try: + user_id = models.create_user( + username, + hash_password(secrets.token_urlsafe(32)), + role="member", + display_name=nom, + draco_sub=sub, + ) + break + except sqlite3.IntegrityError: + # Course sur draco_sub ou username : la correspondance + # gagnante fait foi ; sinon, réessayer un autre nom. + existant = models.get_user_by_draco(sub) + if existant is not None: + return existant + else: + return models.get_user_by_draco(sub) + espaces = models.list_workspaces() + if espaces: + models.set_member(espaces[0]["id"], user_id, "member") + models.log_admin(user_id, "draco.jit", f"{username} (sub {sub})") + return models.get_user(user_id) + + +def resolve_draco_user(claims: dict | None, provisionner: bool = False) -> dict | None: + """Compte Kanbante désigné par des claims draco valides. + + provisionner=False : correspondance et amorçage seulement + (surfaces qui ne doivent pas créer de compte, p. ex. + /api/search). provisionner=True : création JIT si aucune + correspondance n'existe (pont /auth/draco — la « première + connexion draco » du critère « un compte fonctionne + partout »). Un compte désactivé résout à None et n'est + jamais contourné par un JIT (la correspondance existe). + """ + sub = str((claims or {}).get("sub") or "") + if not sub: + return None + lie = models.get_user_by_draco(sub) + if lie is not None: + return lie if lie["active"] else None + if models.count_users() == 1: + seul = models.list_users()[0] + if ( + seul["role"] == "admin" + and seul["active"] + and not seul.get("draco_sub") + ): + # Compte d'amorçage : liaison au premier passage. + models.update_user(seul["id"], draco_sub=sub) + models.log_admin(seul["id"], "draco.link.amorcage", sub) + return models.get_user(seul["id"]) + if provisionner: + cree = provision_draco_user(sub, str((claims or {}).get("name") or "")) + if cree is not None and cree["active"]: + return cree + return None diff --git a/app/models.py b/app/models.py index 503190d..f3694a5 100644 --- a/app/models.py +++ b/app/models.py @@ -1012,12 +1012,14 @@ def create_user( role: str = "member", display_name: str = "", must_change_password: int = 0, + draco_sub: str | None = None, ) -> int: with get_db() as conn: cur = conn.execute( "INSERT INTO users (username, password_hash, role, display_name, " - "must_change_password) VALUES (?, ?, ?, ?, ?)", - (username, password_hash, role, display_name, must_change_password), + "must_change_password, draco_sub) VALUES (?, ?, ?, ?, ?, ?)", + (username, password_hash, role, display_name, + must_change_password, draco_sub), ) return int(cur.lastrowid)