Kanbante 0.46.0 : correspondance draco — create_user(draco_sub) et provisionnement (résolution, amorçage unique admin, JIT membre) (app/models.py, app/accounts.py)

This commit is contained in:
Spark
2026-11-06 13:54:11 +00:00
parent 37aa83ee45
commit 5f19488c47
2 changed files with 134 additions and 2 deletions
+130
View File
@@ -9,7 +9,10 @@ be raised later without invalidating existing hashes.
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import re
import secrets import secrets
import sqlite3
import unicodedata
from app import models from app import models
@@ -66,3 +69,130 @@ def ensure_bootstrap_admin() -> dict | None:
display_name=settings.auth_user, display_name=settings.auth_user,
) )
return models.get_user(user_id) 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-<sub slugifié> ». 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
+4 -2
View File
@@ -1012,12 +1012,14 @@ def create_user(
role: str = "member", role: str = "member",
display_name: str = "", display_name: str = "",
must_change_password: int = 0, must_change_password: int = 0,
draco_sub: str | None = None,
) -> int: ) -> int:
with get_db() as conn: with get_db() as conn:
cur = conn.execute( cur = conn.execute(
"INSERT INTO users (username, password_hash, role, display_name, " "INSERT INTO users (username, password_hash, role, display_name, "
"must_change_password) VALUES (?, ?, ?, ?, ?)", "must_change_password, draco_sub) VALUES (?, ?, ?, ?, ?, ?)",
(username, password_hash, role, display_name, must_change_password), (username, password_hash, role, display_name,
must_change_password, draco_sub),
) )
return int(cur.lastrowid) return int(cur.lastrowid)