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

This commit was merged in pull request #84.
This commit is contained in:
2026-11-06 09:45:08 -05:00
11 changed files with 803 additions and 61 deletions
+12
View File
@@ -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é.
+1 -1
View File
@@ -1 +1 @@
0.45.12 0.46.0
+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
+6 -3
View File
@@ -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
View File
@@ -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
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)
+18 -2
View File
@@ -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">
+1 -1
View File
@@ -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>
+132
View File
@@ -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.
+396
View File
@@ -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"
+6 -3
View File
@@ -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