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:
+130
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user