Merge pull request 'Kanbante 0.46.0 — HUB-16 : correspondance draco par utilisateur (JIT + liaison admin)' (#84) from hub16-correspondance-draco into main
CI / test (push) Failing after 0s
CI / test (push) Failing after 0s
This commit was merged in pull request #84.
This commit is contained in:
@@ -1,3 +1,15 @@
|
|||||||
|
## 0.46.0 — 2026-11-06 — Correspondance draco par utilisateur (HUB-16, carte fusion #130)
|
||||||
|
|
||||||
|
Version **mineure** : c'est une fonction (nouveau comportement d'identité), selon le patron du dépôt (fonctions en mineur — 0.24.0 comptes, 0.25.0 jetons ; les 0.45.x étaient des vendanges et correctifs).
|
||||||
|
|
||||||
|
- **Fin de l'hypothèse mono-utilisateur du pont SSO (ACC-3.2)** : un jeton `draco_session` valide désigne désormais **le compte Kanbante de son identité**. La correspondance vit dans la colonne existante `users.draco_sub` (UNIQUE, depuis la 0.24.0) ; la résolution est centralisée dans `app.accounts.resolve_draco_user()`, seul point d'entrée du pont `/auth/draco` et du contrôle draco de `/api/search`.
|
||||||
|
- **Création JIT** : à la première connexion draco d'une identité sans correspondance, son compte Kanbante est créé automatiquement — nom dérivé des claims (slug du nom affiché, suffixé en cas de collision, jamais d'adoption d'un compte non lié), rôle **membre**, mot de passe local aléatoire haché **non divulgué** (l'accès normal passe par draco), membre de l'espace Principal, geste journalisé (`draco.jit`).
|
||||||
|
- **Compte d'amorçage** : si l'unique compte existant est un admin actif sans liaison, le premier `sub` valide s'y lie au premier passage (journalisé `draco.link.amorcage`) — le compte actuel de l'opérateur garde son accès ; ses voies natives (mot de passe du compte, repli environnement formulaire/Basic) sont inchangées.
|
||||||
|
- **Liaison par l'administrateur** : colonne « Draco (sub) » de `/admin` — saisir le `sub` et « Lier », ou « Délier » ; refus si le `sub` est déjà lié à un autre compte ; permis sur son propre compte (voie manuelle pour l'amorçage). Journalisé (`draco.link.admin` / `draco.unlink.admin`). Procédure et modèle : `docs/correspondance-draco.md`. Le registre matriciel côté Hub viendra avec HUB-19 ; ici, seul le côté Kanbante — la source de vérité locale — est livré.
|
||||||
|
- **`/api/search`** : le cookie draco n'y vaut plus « opérateur » — le `sub` doit désigner un compte (correspondance ou amorçage) ; aucun JIT sur cette surface.
|
||||||
|
- **Non-régressions** : jetons personnels `kbt_` (création, usage `/mcp` et `/api/`, révocation), sessions propres Kanbante, connexion native (avec liaison à la première visite doublement connectée), authentification Basic, pont inerte sans `DRACO_SESSION_SECRET`, compte désactivé refusé partout sans contournement JIT. La réconciliation par étiquettes Kanbante/Gitea n'est pas touchée.
|
||||||
|
- Tests : `tests/test_draco_correspondance.py` (amorçage unique admin, deux identités → deux comptes distincts, JIT sans adoption d'un compte homonyme non lié, liaison/déliaison admin et conflit de `sub`, compte désactivé refusé, `/api/search` par utilisateur, jeton `kbt_` toujours accepté) ; le docstring de `tests/test_draco_sso.py` est mis à jour (le pont n'est plus mono-utilisateur).
|
||||||
|
|
||||||
## 0.45.12 — 2026-11-05 — Barre commune v0.4.1 : correctif des icônes invisibles du menu compte (fusion #125)
|
## 0.45.12 — 2026-11-05 — Barre commune v0.4.1 : correctif des icônes invisibles du menu compte (fusion #125)
|
||||||
|
|
||||||
- **Barre d'apps v0.4.1 vendangée (dracodev-commons, branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11)** : `static/js/draco-barre.js` remplacé **verbatim** — SHA-256 JS `82c4940e…b34f85a42` (preuve dans `tests/test_barre_v041.py`). Le CSS est inchangé (empreinte v0.4.0 conservée) ; le gabarit `_draco_bar.html` est inchangé.
|
- **Barre d'apps v0.4.1 vendangée (dracodev-commons, branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11)** : `static/js/draco-barre.js` remplacé **verbatim** — SHA-256 JS `82c4940e…b34f85a42` (preuve dans `tests/test_barre_v041.py`). Le CSS est inchangé (empreinte v0.4.0 conservée) ; le gabarit `_draco_bar.html` est inchangé.
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+6
-3
@@ -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).
|
les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0).
|
||||||
|
|
||||||
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
|
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
|
||||||
en session Kanbante native. Kanbante est mono-utilisateur
|
en session Kanbante native. Depuis la 0.46.0 (HUB-16), la
|
||||||
(identifiants configurés par l'environnement) : un jeton valide
|
correspondance est PAR UTILISATEUR : le ``sub`` des claims désigne
|
||||||
désigne l'opérateur configuré — pas de table de liens. Sans secret
|
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.
|
configuré, module inerte.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
+97
-49
@@ -391,15 +391,24 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
and _bearer_user(request) is not None:
|
and _bearer_user(request) is not None:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
# Recherche partout (#68) : /api/search accepte aussi le jeton
|
# Recherche partout (#68) : /api/search accepte aussi le jeton
|
||||||
# SSO draco_session (mono-utilisateur : un jeton valide désigne
|
# SSO draco_session — depuis la 0.46.0 (HUB-16), par
|
||||||
# l'opérateur), comme les API de recherche des sœurs. Les
|
# utilisateur : le sub doit désigner un compte Kanbante
|
||||||
# preflights OPTIONS ne portent jamais de cookies : ils sont
|
# (correspondance existante ou compte d'amorçage lié au
|
||||||
# répondus par RechercheCorsMiddleware avant d'arriver ici.
|
# 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":
|
if path == "/api/search":
|
||||||
try:
|
try:
|
||||||
|
from app import accounts as accounts_service
|
||||||
from app import draco
|
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)
|
return await call_next(request)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -571,67 +580,50 @@ def draco_bridge(request: Request, next: str = "/"):
|
|||||||
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
|
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
|
||||||
|
|
||||||
Point de retour de ``GET /sso/authorize`` de l'émettrice
|
Point de retour de ``GET /sso/authorize`` de l'émettrice
|
||||||
(ObsiGate). Depuis 0.24.0 la liaison est PAR UTILISATEUR :
|
(ObsiGate). Depuis la 0.46.0 (HUB-16), la correspondance est
|
||||||
si le ``sub`` draco est lié à un compte, sa session est
|
PAR UTILISATEUR et l'hypothèse mono-utilisateur a disparu —
|
||||||
créée ; si une session native est déjà ouverte, le ``sub``
|
résolution centralisée dans app.accounts.resolve_draco_user :
|
||||||
est lié à ce compte (première visite doublement connectée) ;
|
|
||||||
sans compte lié, retour au login — la connexion native qui
|
- session native déjà ouverte : le ``sub`` libre est lié à
|
||||||
suit crée le lien (voir login_submit). Sans utilisateurs en
|
ce compte (première visite doublement connectée, inchangé) ;
|
||||||
base, comportement historique : session de l'opérateur.
|
- ``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``.
|
Inerte sans ``DRACO_SESSION_SECRET``.
|
||||||
"""
|
"""
|
||||||
|
from app import accounts as accounts_service
|
||||||
from app import draco
|
from app import draco
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
claims = draco.claims_for(request)
|
claims = draco.claims_for(request)
|
||||||
if not settings.auth_enabled or claims is None:
|
if not settings.auth_enabled or claims is None:
|
||||||
return RedirectResponse("/login", status_code=303)
|
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.
|
# Déjà connecté nativement : lier le sub à ce compte.
|
||||||
current = _current_user(request)
|
current = _current_user(request)
|
||||||
if current is not None and current.get("id") is not None:
|
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:
|
if models.get_user_by_draco(sub) is None:
|
||||||
models.update_user(current["id"], draco_sub=sub)
|
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)
|
return RedirectResponse(_safe_next(next), status_code=303)
|
||||||
# Compte lié à ce sub : ouvrir sa session.
|
# Correspondance, amorçage ou création JIT : ouvrir la
|
||||||
linked = models.get_user_by_draco(sub) if sub else None
|
# session du compte que ce sub désigne — et seulement lui.
|
||||||
if linked is not None and linked["active"]:
|
user = accounts_service.resolve_draco_user(claims, provisionner=True)
|
||||||
|
if user is not None:
|
||||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
_make_session(linked["username"]),
|
_make_session(user["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),
|
|
||||||
max_age=SESSION_DAYS * 86400,
|
max_age=SESSION_DAYS * 86400,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
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)
|
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")
|
@app.get("/admin/connections")
|
||||||
async def admin_connections(request: Request, msg: str = "", err: str = ""):
|
async def admin_connections(request: Request, msg: str = "", err: str = ""):
|
||||||
admin = _require_admin(request)
|
admin = _require_admin(request)
|
||||||
|
|||||||
+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)
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,10 @@
|
|||||||
|
|
||||||
<section class="briefing-card">
|
<section class="briefing-card">
|
||||||
<h2>Utilisateurs ({{ users|length }})</h2>
|
<h2>Utilisateurs ({{ users|length }})</h2>
|
||||||
|
<p class="notes-hint">Correspondance draco (SSO, HUB-16) : chaque identité draco (<code>sub</code>) désigne <strong>son</strong> compte Kanbante. Un compte de suite sans correspondance est créé automatiquement à sa première connexion draco (JIT, rôle membre). Pour un compte Kanbante <strong>préexistant</strong>, c'est ici que l'administrateur établit la liaison : saisir le <code>sub</code> draco du compte puis « Lier » (champ affiché = délier). Un <code>sub</code> ne peut être lié qu'à un seul compte. Procédure complète : <code>docs/correspondance-draco.md</code>.</p>
|
||||||
<table class="proj-table">
|
<table class="proj-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
|
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco (sub)</th><th>Dernière connexion</th><th>Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
@@ -38,7 +39,22 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>{% if u.active %}Actif{% else %}Désactivé{% endif %}</td>
|
<td>{% if u.active %}Actif{% else %}Désactivé{% endif %}</td>
|
||||||
<td>{% if u.draco_sub %}lié{% else %}—{% endif %}</td>
|
<td>
|
||||||
|
{% if u.draco_sub %}
|
||||||
|
<code>{{ u.draco_sub }}</code>
|
||||||
|
<form method="post" action="/admin/users/{{ u.id }}/draco" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="draco_sub" value="">
|
||||||
|
<button type="submit" class="btn-mini">Délier</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/admin/users/{{ u.id }}/draco" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="text" name="draco_sub" placeholder="sub draco" size="12" autocomplete="off">
|
||||||
|
<button type="submit" class="btn-mini">Lier</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ u.last_login_at or '—' }}</td>
|
<td>{{ u.last_login_at or '—' }}</td>
|
||||||
<td class="admin-actions">
|
<td class="admin-actions">
|
||||||
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
|
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<button type="submit" class="btn-mini">Délier</button>
|
<button type="submit" class="btn-mini">Délier</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate).</p>
|
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate) ; un administrateur peut aussi l'établir depuis la page Administration. Sans compte lié, la première connexion draco d'une identité de suite crée automatiquement son propre compte Kanbante (JIT).</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Correspondance draco par utilisateur (HUB-16, 0.46.0)
|
||||||
|
|
||||||
|
Kanbante participe au SSO de la suite dracodev : ObsiGate émet le
|
||||||
|
cookie `draco_session` (JWS HS256, claims `sub`, `name`, `iat`,
|
||||||
|
`exp`, `jti`), Kanbante le valide localement (`app/draco.py`) et le
|
||||||
|
pont `GET /auth/draco` le convertit en session Kanbante native.
|
||||||
|
|
||||||
|
Depuis la 0.46.0, un jeton draco valide désigne **le compte
|
||||||
|
Kanbante de cette identité** — et aucun autre. L'ancienne
|
||||||
|
hypothèse mono-utilisateur (« un jeton valide = l'opérateur ») a
|
||||||
|
disparu. C'est ce qui fait tenir le critère de la suite : « un
|
||||||
|
compte créé fonctionne dans toutes les applications ».
|
||||||
|
|
||||||
|
## Où vit la correspondance
|
||||||
|
|
||||||
|
Dans le modèle existant des comptes : la colonne
|
||||||
|
**`users.draco_sub`** (texte, `UNIQUE`, présente depuis la 0.24.0)
|
||||||
|
porte le `sub` draco lié à chaque compte Kanbante.
|
||||||
|
`models.get_user_by_draco(sub)` est la résolution canonique ; la
|
||||||
|
règle de décision complète vit dans
|
||||||
|
`app.accounts.resolve_draco_user()`, seul point d'entrée du pont
|
||||||
|
et du contrôle draco de `/api/search`. La correspondance est donc
|
||||||
|
biunivoque : un `sub` ↔ un compte, garanti par la contrainte
|
||||||
|
d'unicité.
|
||||||
|
|
||||||
|
## Résolution d'une connexion draco (`/auth/draco`)
|
||||||
|
|
||||||
|
Dans l'ordre :
|
||||||
|
|
||||||
|
1. **Session native déjà ouverte** : si le navigateur porte à la
|
||||||
|
fois une session Kanbante et un cookie draco dont le `sub`
|
||||||
|
n'est lié à aucun compte, le `sub` est lié au compte de la
|
||||||
|
session (première visite doublement connectée — mécanisme
|
||||||
|
historique, inchangé, aussi appliqué à la connexion native
|
||||||
|
dans `login_submit`).
|
||||||
|
2. **Correspondance existante** : la session du compte lié est
|
||||||
|
ouverte. Un compte **désactivé** n'ouvre aucune session et
|
||||||
|
n'est jamais contourné (voir § Désactivation).
|
||||||
|
3. **Compte d'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 et sa session est ouverte. C'est le cas du
|
||||||
|
compte créé à l'installation depuis l'environnement
|
||||||
|
(`KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD`, Bruno) : sa
|
||||||
|
correspondance s'établit au premier passage, sans le verrouiller
|
||||||
|
dehors. Ses autres accès restent inchangés dans tous les cas :
|
||||||
|
connexion native avec le mot de passe du compte, et repli
|
||||||
|
d'urgence par les identifiants d'environnement (formulaire et
|
||||||
|
Basic), qui ne dépendent pas de draco.
|
||||||
|
4. **Création JIT** : sinon, un compte Kanbante est créé pour
|
||||||
|
cette identité (détail ci-dessous) et sa session est ouverte.
|
||||||
|
|
||||||
|
Un `sub` inconnu n'ouvre **jamais** la session de l'opérateur
|
||||||
|
d'environnement ni celle d'un compte existant non lié — y compris
|
||||||
|
si le nom affiché draco ressemble au nom d'un compte local : le
|
||||||
|
JIT crée toujours un compte **nouveau** (nom suffixé `-2`, `-3`…
|
||||||
|
en cas de collision), jamais une adoption.
|
||||||
|
|
||||||
|
## Création JIT (juste-à-temps)
|
||||||
|
|
||||||
|
À la première connexion draco d'une identité sans correspondance :
|
||||||
|
|
||||||
|
- **nom d'utilisateur** : nom affiché des claims slugifié
|
||||||
|
(`Alice Exemple` → `alice-exemple`), libre ou suffixé ;
|
||||||
|
repli `draco-<sub>` si le nom est inexploitable ;
|
||||||
|
- **nom d'affichage** : le `name` des claims ;
|
||||||
|
- **rôle** : `member` (utilisateur standard) — jamais admin ;
|
||||||
|
- **mot de passe local** : secret aléatoire, haché (PBKDF2 comme
|
||||||
|
tous les comptes), **jamais divulgué** — l'accès normal passe
|
||||||
|
par draco. Un administrateur peut ensuite poser un mot de
|
||||||
|
passe local depuis `/admin` (bouton « Réinitialiser ») si un
|
||||||
|
accès local est voulu ;
|
||||||
|
- **espace** : le compte rejoint l'espace Principal comme membre,
|
||||||
|
comme tout utilisateur créé par l'administration ;
|
||||||
|
- le geste est journalisé (`draco.jit` dans le journal
|
||||||
|
d'administration).
|
||||||
|
|
||||||
|
## Liaison par l'administrateur (comptes préexistants)
|
||||||
|
|
||||||
|
Pour associer un compte Kanbante existant à une identité draco
|
||||||
|
sans attendre un premier passage :
|
||||||
|
|
||||||
|
1. Se connecter à Kanbante avec un compte **admin**.
|
||||||
|
2. Ouvrir **Administration** (`/admin`), section **Utilisateurs**.
|
||||||
|
3. Dans la colonne **Draco (sub)** du compte visé, saisir le
|
||||||
|
`sub` draco de l'identité (l'identifiant du compte de suite
|
||||||
|
chez l'émettrice, ObsiGate) et cliquer **Lier**.
|
||||||
|
4. La liaison est immédiate et journalisée (`draco.link.admin`).
|
||||||
|
Le bouton **Délier** de la même colonne la retire
|
||||||
|
(`draco.unlink.admin`).
|
||||||
|
|
||||||
|
Gardes : un `sub` déjà lié à un autre compte est refusé avec un
|
||||||
|
message d'erreur (délier l'autre compte d'abord). L'administrateur
|
||||||
|
peut lier **son propre** compte — c'est la voie manuelle pour le
|
||||||
|
compte d'amorçage si le premier passage ne l'a pas lié.
|
||||||
|
|
||||||
|
Chaque utilisateur peut aussi voir sa propre liaison dans
|
||||||
|
**Mon profil → Liaison draco (SSO)** et la délier lui-même.
|
||||||
|
|
||||||
|
> Le registre matriciel des rôles par application côté Hub
|
||||||
|
> (carte HUB-19) lira cette correspondance ; la présente carte ne
|
||||||
|
> livre que le côté Kanbante, qui est la source de vérité locale.
|
||||||
|
|
||||||
|
## Surfaces voisines
|
||||||
|
|
||||||
|
- **`GET /api/search`** (« Rechercher partout ») : le cookie draco
|
||||||
|
n'y est plus accepté « en tant qu'opérateur ». Il faut que le
|
||||||
|
`sub` désigne un compte (correspondance existante ou compte
|
||||||
|
d'amorçage lié au passage). Aucune création JIT sur cette
|
||||||
|
surface : la création a lieu à la première vraie connexion, par
|
||||||
|
le pont. Les autres modes d'accès à `/api/search` (session,
|
||||||
|
Basic, jeton `kbt_`) sont inchangés.
|
||||||
|
- **Désactivation** : désactiver un compte dans `/admin` coupe
|
||||||
|
tous ses accès — session native, pont draco (la correspondance
|
||||||
|
résout à « refusé ») et jetons `kbt_`. Réactiver le compte
|
||||||
|
rétablit la correspondance telle quelle.
|
||||||
|
|
||||||
|
## Non-régressions garanties
|
||||||
|
|
||||||
|
- **Jetons personnels `kbt_`** : création, usage sur `/mcp` et
|
||||||
|
`/api/`, révocation — inchangés ; un jeton n'ouvre toujours pas
|
||||||
|
l'interface web.
|
||||||
|
- **Sessions propres Kanbante** et **connexion native**
|
||||||
|
(formulaire, mot de passe de la base) : inchangées, y compris
|
||||||
|
la liaison automatique à la première visite doublement
|
||||||
|
connectée.
|
||||||
|
- **Authentification Basic** (identifiants d'environnement, pour
|
||||||
|
les scripts) : inchangée.
|
||||||
|
- **Pont inerte sans `DRACO_SESSION_SECRET`** : sans secret
|
||||||
|
configuré, `/auth/draco` renvoie au login, comme avant.
|
||||||
|
- La réconciliation Kanbante/Gitea par étiquettes (adoption d'une
|
||||||
|
colonne seulement si exactement une étiquette `kanban:*` est
|
||||||
|
reconnue) n'est pas touchée par cette carte.
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""HUB-16 (0.46.0) — correspondance draco PAR UTILISATEUR.
|
||||||
|
|
||||||
|
Le pont SSO ne désigne plus « l'opérateur » : chaque identité
|
||||||
|
draco (claim ``sub``) désigne SON compte Kanbante, via la colonne
|
||||||
|
users.draco_sub. Couvert ici :
|
||||||
|
|
||||||
|
- amorçage : l'unique compte admin sans liaison se lie au
|
||||||
|
premier passage (le compte existant garde son accès) ;
|
||||||
|
- JIT : deux identités distinctes → deux comptes distincts,
|
||||||
|
rôle membre, mot de passe local non divulgué/inutilisable ;
|
||||||
|
- le JIT n'adopte jamais un compte préexistant non lié, même
|
||||||
|
homonyme : il crée un compte nouveau, suffixé ;
|
||||||
|
- liaison / déliaison par l'administrateur depuis /admin,
|
||||||
|
conflit de sub refusé ;
|
||||||
|
- compte désactivé : correspondance refusée, aucun contournement ;
|
||||||
|
- /api/search : un jeton draco sans correspondance ne suffit plus,
|
||||||
|
un jeton lié passe ;
|
||||||
|
- non-régression : un jeton personnel kbt_ reste accepté sur les
|
||||||
|
surfaces machine pendant que l'authentification est active.
|
||||||
|
|
||||||
|
La base de test est partagée par toute la session (voir
|
||||||
|
conftest) : les comptes créés ici portent des noms/subs uniques
|
||||||
|
« hub16-… » et sont supprimés au teardown de chaque test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app import accounts, draco, models
|
||||||
|
|
||||||
|
SECRET = "secret-partage-hub16"
|
||||||
|
|
||||||
|
SUBS_TEST = (
|
||||||
|
"hub16-alice", "hub16-bob", "hub16-carole", "hub16-dave",
|
||||||
|
"hub16-erin", "hub16-franck", "hub16-amorce", "hub16-sans-lien",
|
||||||
|
"hub16-recherche",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _jeton(sub, nom="Alice Exemple", secret=SECRET):
|
||||||
|
def b64(d: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||||
|
payload = b64(json.dumps({
|
||||||
|
"sub": sub, "name": nom, "iat": int(time.time()),
|
||||||
|
"exp": int(time.time()) + 3600, "jti": f"jti-{sub}",
|
||||||
|
}).encode())
|
||||||
|
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
|
||||||
|
hashlib.sha256).digest()
|
||||||
|
return f"{header}.{payload}.{b64(sig)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _nettoyer():
|
||||||
|
"""Supprime les comptes créés par ces tests (et leurs traces)."""
|
||||||
|
with models.get_db() as conn:
|
||||||
|
ids = [
|
||||||
|
r[0] for r in conn.execute(
|
||||||
|
"SELECT id FROM users WHERE draco_sub IN ({})".format(
|
||||||
|
",".join("?" * len(SUBS_TEST))
|
||||||
|
),
|
||||||
|
SUBS_TEST,
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
ids += [
|
||||||
|
r[0] for r in conn.execute(
|
||||||
|
"SELECT id FROM users WHERE username IN"
|
||||||
|
" ('hub16-dave', 'hub16-erin', 'carole-exemple')"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
for uid in ids:
|
||||||
|
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM workspace_members WHERE user_id = ?", (uid,)
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
|
||||||
|
|
||||||
|
|
||||||
|
def _restaurer_temoin():
|
||||||
|
"""Délie le compte témoin d'amorçage (jamais supprimé)."""
|
||||||
|
temoin = models.get_user_by_draco("hub16-temoin-amorce")
|
||||||
|
if temoin is not None:
|
||||||
|
models.update_user(temoin["id"], draco_sub=None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client_hub16(monkeypatch, fake_gitea):
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
|
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||||
|
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||||
|
draco._revoked_cache.clear()
|
||||||
|
_restaurer_temoin()
|
||||||
|
_nettoyer()
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
yield client
|
||||||
|
_restaurer_temoin()
|
||||||
|
_nettoyer()
|
||||||
|
draco._revoked_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _pont(client, sub, nom="Alice Exemple"):
|
||||||
|
client.cookies.set("draco_session", _jeton(sub, nom))
|
||||||
|
return client.get("/auth/draco", follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _profil_username(client) -> str:
|
||||||
|
r = client.get("/profile")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# « <username> · rôle : … » dans l'en-tête de la page profil.
|
||||||
|
return r.text.split("page-sub\">", 1)[1].split(" ·", 1)[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_admin_sans_liaison():
|
||||||
|
"""(compte, vrai) si l'état partagé est le cas d'amorçage pur."""
|
||||||
|
users = models.list_users()
|
||||||
|
if len(users) == 1 and users[0]["role"] == "admin" \
|
||||||
|
and not users[0].get("draco_sub"):
|
||||||
|
return users[0], True
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Amorçage ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_amorcage_unique_admin_se_lie_au_premier_passage(client_hub16):
|
||||||
|
admin, pur = _unique_admin_sans_liaison()
|
||||||
|
if not pur:
|
||||||
|
pytest.skip("base partagée : le cas d'amorçage pur n'est pas en place")
|
||||||
|
avant = models.count_users()
|
||||||
|
r = _pont(client_hub16, "hub16-amorce", "Bruno")
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||||
|
assert "kanbante_session" in r.headers.get("set-cookie", "")
|
||||||
|
# Aucun compte créé : le sub s'est lié au compte admin existant.
|
||||||
|
assert models.count_users() == avant
|
||||||
|
lie = models.get_user_by_draco("hub16-amorce")
|
||||||
|
assert lie is not None and lie["id"] == admin["id"]
|
||||||
|
assert lie["role"] == "admin"
|
||||||
|
assert _profil_username(client_hub16) == admin["username"]
|
||||||
|
# Nettoyage propre à ce test : rendre l'amorçage à son état.
|
||||||
|
models.update_user(admin["id"], draco_sub=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Création JIT ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_jit_deux_identites_deux_comptes_distincts(client_hub16):
|
||||||
|
admin, pur = _unique_admin_sans_liaison()
|
||||||
|
if pur:
|
||||||
|
# Neutraliser l'amorçage : les scénarios JIT doivent être purs.
|
||||||
|
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
|
||||||
|
try:
|
||||||
|
r = _pont(client_hub16, "hub16-alice", "Alice Exemple")
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||||
|
assert _profil_username(client_hub16) == "alice-exemple"
|
||||||
|
alice = models.get_user_by_draco("hub16-alice")
|
||||||
|
assert alice is not None
|
||||||
|
assert alice["username"] == "alice-exemple"
|
||||||
|
assert alice["display_name"] == "Alice Exemple"
|
||||||
|
assert alice["role"] == "member"
|
||||||
|
# Mot de passe local aléatoire : ni le mot de passe
|
||||||
|
# d'environnement ni le nom ne l'ouvrent.
|
||||||
|
assert accounts.verify_password("motdepasse", alice["password_hash"]) is False
|
||||||
|
assert alice["password_hash"].startswith("pbkdf2$")
|
||||||
|
|
||||||
|
client_hub16.cookies.delete("kanbante_session")
|
||||||
|
r = _pont(client_hub16, "hub16-bob", "Bob Exemple")
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||||
|
assert _profil_username(client_hub16) == "bob-exemple"
|
||||||
|
bob = models.get_user_by_draco("hub16-bob")
|
||||||
|
assert bob is not None and bob["id"] != alice["id"]
|
||||||
|
|
||||||
|
# Deuxième passage d'Alice : MÊME compte, pas de doublon.
|
||||||
|
client_hub16.cookies.delete("kanbante_session")
|
||||||
|
avant = models.count_users()
|
||||||
|
_pont(client_hub16, "hub16-alice", "Alice Exemple")
|
||||||
|
assert models.count_users() == avant
|
||||||
|
assert models.get_user_by_draco("hub16-alice")["id"] == alice["id"]
|
||||||
|
assert _profil_username(client_hub16) == "alice-exemple"
|
||||||
|
finally:
|
||||||
|
if pur:
|
||||||
|
models.update_user(admin["id"], draco_sub=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jit_n_adopte_pas_un_compte_homonyme_non_lie(client_hub16):
|
||||||
|
admin, pur = _unique_admin_sans_liaison()
|
||||||
|
if pur:
|
||||||
|
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
|
||||||
|
try:
|
||||||
|
local_id = models.create_user(
|
||||||
|
"carole-exemple", accounts.hash_password("local-carole"),
|
||||||
|
role="member", display_name="Carole Exemple",
|
||||||
|
)
|
||||||
|
r = _pont(client_hub16, "hub16-carole", "Carole Exemple")
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||||
|
# Le compte préexistant n'a PAS été lié ni utilisé…
|
||||||
|
local = models.get_user(local_id)
|
||||||
|
assert local["draco_sub"] is None
|
||||||
|
# …un compte nouveau, suffixé, porte la correspondance.
|
||||||
|
cree = models.get_user_by_draco("hub16-carole")
|
||||||
|
assert cree is not None and cree["id"] != local_id
|
||||||
|
assert cree["username"] == "carole-exemple-2"
|
||||||
|
assert _profil_username(client_hub16) == "carole-exemple-2"
|
||||||
|
finally:
|
||||||
|
if pur:
|
||||||
|
models.update_user(admin["id"], draco_sub=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compte_desactive_refuse_sans_contournement(client_hub16):
|
||||||
|
admin, pur = _unique_admin_sans_liaison()
|
||||||
|
if pur:
|
||||||
|
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
|
||||||
|
try:
|
||||||
|
_pont(client_hub16, "hub16-franck", "Franck Exemple")
|
||||||
|
franck = models.get_user_by_draco("hub16-franck")
|
||||||
|
assert franck is not None
|
||||||
|
models.update_user(franck["id"], active=0)
|
||||||
|
client_hub16.cookies.delete("kanbante_session")
|
||||||
|
avant = models.count_users()
|
||||||
|
r = _pont(client_hub16, "hub16-franck", "Franck Exemple")
|
||||||
|
# Refusé au login, aucune session, aucun nouveau compte.
|
||||||
|
assert r.headers["location"] == "/login"
|
||||||
|
assert "kanbante_session" not in r.headers.get("set-cookie", "")
|
||||||
|
assert models.count_users() == avant
|
||||||
|
finally:
|
||||||
|
if pur:
|
||||||
|
models.update_user(admin["id"], draco_sub=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Liaison par l'administrateur ──────────────────────────────
|
||||||
|
|
||||||
|
def _jeton_csrf_via_login(client) -> str:
|
||||||
|
"""Charge la page de connexion (elle pose le cookie CSRF).
|
||||||
|
|
||||||
|
csrf_from() charge « / », qui répond 401 JSON sans cookie
|
||||||
|
quand l'authentification est active et qu'aucune session
|
||||||
|
n'est ouverte — la page /login, elle, est rendue (render()
|
||||||
|
pose le cookie) dans tous les cas.
|
||||||
|
"""
|
||||||
|
client.get("/login")
|
||||||
|
return client.cookies.get("kanbante_csrf") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _connecter_admin(client):
|
||||||
|
token = _jeton_csrf_via_login(client)
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "bruno", "password": "motdepasse",
|
||||||
|
"next": "/", "csrf_token": token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303, r.text
|
||||||
|
assert "kanbante_session" in r.headers.get("set-cookie", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_liaison_par_administrateur(client_hub16):
|
||||||
|
dave_id = models.create_user(
|
||||||
|
"hub16-dave", accounts.hash_password("local-dave"),
|
||||||
|
role="member", display_name="Dave",
|
||||||
|
)
|
||||||
|
erin_id = models.create_user(
|
||||||
|
"hub16-erin", accounts.hash_password("local-erin"),
|
||||||
|
role="member", display_name="Erin",
|
||||||
|
)
|
||||||
|
_connecter_admin(client_hub16)
|
||||||
|
# L'écran d'administration expose le mécanisme de liaison.
|
||||||
|
r = client_hub16.get("/admin")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "/draco" in r.text and "Lier" in r.text
|
||||||
|
|
||||||
|
# Lier Dave au sub hub16-dave.
|
||||||
|
r = client_hub16.post(
|
||||||
|
f"/admin/users/{dave_id}/draco",
|
||||||
|
data={"draco_sub": "hub16-dave",
|
||||||
|
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303 and r.headers["location"].startswith("/admin?msg=")
|
||||||
|
assert models.get_user(dave_id)["draco_sub"] == "hub16-dave"
|
||||||
|
|
||||||
|
# Conflit : le même sub pour Erin est refusé, Erin inchangée.
|
||||||
|
r = client_hub16.post(
|
||||||
|
f"/admin/users/{erin_id}/draco",
|
||||||
|
data={"draco_sub": "hub16-dave",
|
||||||
|
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303 and "err=" in r.headers["location"]
|
||||||
|
assert models.get_user(erin_id)["draco_sub"] is None
|
||||||
|
|
||||||
|
# Le pont route désormais ce sub vers Dave, et seulement lui.
|
||||||
|
client_hub16.cookies.delete("kanbante_session")
|
||||||
|
r = _pont(client_hub16, "hub16-dave", "Dave")
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||||
|
assert _profil_username(client_hub16) == "hub16-dave"
|
||||||
|
|
||||||
|
# Déliaison par l'administrateur (champ vide).
|
||||||
|
_connecter_admin(client_hub16)
|
||||||
|
r = client_hub16.post(
|
||||||
|
f"/admin/users/{dave_id}/draco",
|
||||||
|
data={"draco_sub": "",
|
||||||
|
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert models.get_user(dave_id)["draco_sub"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_liaison_refusee_aux_non_admins(client_hub16):
|
||||||
|
dave_id = models.create_user(
|
||||||
|
"hub16-dave", accounts.hash_password("local-dave"),
|
||||||
|
role="member", display_name="Dave",
|
||||||
|
)
|
||||||
|
# Connexion native de Dave (membre) : aucun droit d'administration.
|
||||||
|
token = _jeton_csrf_via_login(client_hub16)
|
||||||
|
r = client_hub16.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "hub16-dave", "password": "local-dave",
|
||||||
|
"next": "/", "csrf_token": token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
r = client_hub16.post(
|
||||||
|
f"/admin/users/{dave_id}/draco",
|
||||||
|
data={"draco_sub": "hub16-dave",
|
||||||
|
"csrf_token": client_hub16.cookies.get("kanbante_csrf")},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert models.get_user(dave_id)["draco_sub"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── /api/search par utilisateur ───────────────────────────────
|
||||||
|
|
||||||
|
def test_api_search_draco_par_utilisateur(client_hub16):
|
||||||
|
admin, pur = _unique_admin_sans_liaison()
|
||||||
|
if pur:
|
||||||
|
models.update_user(admin["id"], draco_sub="hub16-temoin-amorce")
|
||||||
|
try:
|
||||||
|
# Jeton draco valide mais SANS correspondance : refusé
|
||||||
|
# (il ne désigne plus l'opérateur).
|
||||||
|
client_hub16.cookies.set(
|
||||||
|
"draco_session", _jeton("hub16-sans-lien", "Sans Lien")
|
||||||
|
)
|
||||||
|
r = client_hub16.get("/api/search", params={"q": "demo"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
# Après création JIT par le pont, le même chemin passe.
|
||||||
|
_pont(client_hub16, "hub16-recherche", "Recherche Exemple")
|
||||||
|
client_hub16.cookies.delete("kanbante_session")
|
||||||
|
client_hub16.cookies.set(
|
||||||
|
"draco_session", _jeton("hub16-recherche", "Recherche Exemple")
|
||||||
|
)
|
||||||
|
r = client_hub16.get("/api/search", params={"q": "demo"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["query"] == "demo"
|
||||||
|
finally:
|
||||||
|
if pur:
|
||||||
|
models.update_user(admin["id"], draco_sub=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Non-régression : jetons kbt_ ──────────────────────────────
|
||||||
|
|
||||||
|
def test_jeton_kbt_toujours_accepte(client_hub16):
|
||||||
|
uid = models.create_user(
|
||||||
|
"hub16-erin", accounts.hash_password("local-erin"),
|
||||||
|
role="member", display_name="Erin Jetons",
|
||||||
|
)
|
||||||
|
user = models.get_user(uid)
|
||||||
|
_row, clair = models.create_api_token(uid, "service-agents")
|
||||||
|
assert clair.startswith("kbt_")
|
||||||
|
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
|
"params": {"protocolVersion": "2024-11-05"}}
|
||||||
|
# Sans jeton : 401 ; avec le jeton Bearer : le handshake passe.
|
||||||
|
assert client_hub16.post("/mcp", json=corps).status_code == 401
|
||||||
|
r = client_hub16.post(
|
||||||
|
"/mcp", json=corps, headers={"Authorization": f"Bearer {clair}"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
|
||||||
|
# Le jeton sert aussi /api/search (surface machine)…
|
||||||
|
r = client_hub16.get(
|
||||||
|
"/api/search", params={"q": "demo"},
|
||||||
|
headers={"Authorization": f"Bearer {clair}"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
# …mais jamais l'interface web.
|
||||||
|
assert client_hub16.get(
|
||||||
|
"/", headers={"Authorization": f"Bearer {clair}",
|
||||||
|
"Accept": "application/json"},
|
||||||
|
).status_code == 401
|
||||||
|
assert user["username"] == "hub16-erin"
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
|
Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
|
||||||
(secret partagé, HS256) et converti en session Kanbante native par
|
(secret partagé, HS256) et converti en session Kanbante native par
|
||||||
GET /auth/draco. Kanbante est mono-utilisateur : un jeton valide
|
GET /auth/draco. Depuis la 0.46.0 (HUB-16), la correspondance est
|
||||||
ouvre la session de l'opérateur configuré. Sans
|
par utilisateur (users.draco_sub, création JIT) — voir
|
||||||
DRACO_SESSION_SECRET, le pont renvoie simplement au login.
|
tests/test_draco_correspondance.py. Les cas couverts ici restent
|
||||||
|
valides : dans ce module, le seul compte est l'admin d'amorçage,
|
||||||
|
auquel le premier jeton valide se lie (cas d'amorçage documenté).
|
||||||
|
Sans DRACO_SESSION_SECRET, le pont renvoie simplement au login.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|||||||
Reference in New Issue
Block a user