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)
|
||||
|
||||
- **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 hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
|
||||
from app import models
|
||||
|
||||
@@ -66,3 +69,130 @@ def ensure_bootstrap_admin() -> dict | None:
|
||||
display_name=settings.auth_user,
|
||||
)
|
||||
return models.get_user(user_id)
|
||||
|
||||
|
||||
# ── Correspondance draco par utilisateur (HUB-16, 0.46.0) ────
|
||||
# La correspondance identité draco ↔ compte Kanbante vit dans la
|
||||
# colonne users.draco_sub (UNIQUE, présente depuis la 0.24.0) :
|
||||
# models.get_user_by_draco(sub) est la résolution canonique.
|
||||
# resolve_draco_user() est l'unique point d'entrée du pont SSO
|
||||
# (app.main.draco_bridge) et du contrôle draco de /api/search :
|
||||
#
|
||||
# 1. correspondance existante → son compte (None si désactivé) ;
|
||||
# 2. amorçage : s'il n'existe qu'UN seul compte et que c'est un
|
||||
# admin actif sans liaison, le premier sub valide s'y lie —
|
||||
# le compte d'amorçage (celui de Bruno, créé depuis
|
||||
# l'environnement) garde ainsi son accès sans verrouillage ;
|
||||
# 3. sinon, si provisionner=True : création JIT d'un compte
|
||||
# propre à cette identité (voir provision_draco_user).
|
||||
#
|
||||
# Un sub inconnu ne désigne JAMAIS un compte existant non lié
|
||||
# (ni l'opérateur d'environnement, ni un compte dont le nom
|
||||
# ressemblerait au nom affiché draco) : la liaison d'un compte
|
||||
# préexistant est un geste d'administrateur (/admin, voir
|
||||
# docs/correspondance-draco.md) ou la première visite
|
||||
# doublement connectée (session native + cookie draco).
|
||||
|
||||
|
||||
def _slug_draco(valeur: str) -> str:
|
||||
texte = unicodedata.normalize("NFKD", valeur or "")
|
||||
texte = texte.encode("ascii", "ignore").decode("ascii").lower()
|
||||
return re.sub(r"[^a-z0-9]+", "-", texte).strip("-")[:40].strip("-")
|
||||
|
||||
|
||||
def _nom_utilisateur_draco(nom_affiche: str, sub: str) -> str:
|
||||
"""Nom d'utilisateur libre dérivé des claims (jamais réutilisé).
|
||||
|
||||
Base : le nom affiché slugifié (« Alice Exemple » →
|
||||
« alice-exemple ») ; repli « draco-<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).
|
||||
|
||||
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
|
||||
en session Kanbante native. Kanbante est mono-utilisateur
|
||||
(identifiants configurés par l'environnement) : un jeton valide
|
||||
désigne l'opérateur configuré — pas de table de liens. Sans secret
|
||||
en session Kanbante native. Depuis la 0.46.0 (HUB-16), la
|
||||
correspondance est PAR UTILISATEUR : le ``sub`` des claims désigne
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
+97
-49
@@ -391,15 +391,24 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
and _bearer_user(request) is not None:
|
||||
return await call_next(request)
|
||||
# Recherche partout (#68) : /api/search accepte aussi le jeton
|
||||
# SSO draco_session (mono-utilisateur : un jeton valide désigne
|
||||
# l'opérateur), comme les API de recherche des sœurs. Les
|
||||
# preflights OPTIONS ne portent jamais de cookies : ils sont
|
||||
# répondus par RechercheCorsMiddleware avant d'arriver ici.
|
||||
# SSO draco_session — depuis la 0.46.0 (HUB-16), par
|
||||
# utilisateur : le sub doit désigner un compte Kanbante
|
||||
# (correspondance existante ou compte d'amorçage lié au
|
||||
# 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":
|
||||
try:
|
||||
from app import accounts as accounts_service
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -571,67 +580,50 @@ def draco_bridge(request: Request, next: str = "/"):
|
||||
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
|
||||
|
||||
Point de retour de ``GET /sso/authorize`` de l'émettrice
|
||||
(ObsiGate). Depuis 0.24.0 la liaison est PAR UTILISATEUR :
|
||||
si le ``sub`` draco est lié à un compte, sa session est
|
||||
créée ; si une session native est déjà ouverte, le ``sub``
|
||||
est lié à ce compte (première visite doublement connectée) ;
|
||||
sans compte lié, retour au login — la connexion native qui
|
||||
suit crée le lien (voir login_submit). Sans utilisateurs en
|
||||
base, comportement historique : session de l'opérateur.
|
||||
(ObsiGate). Depuis la 0.46.0 (HUB-16), la correspondance est
|
||||
PAR UTILISATEUR et l'hypothèse mono-utilisateur a disparu —
|
||||
résolution centralisée dans app.accounts.resolve_draco_user :
|
||||
|
||||
- session native déjà ouverte : le ``sub`` libre est lié à
|
||||
ce compte (première visite doublement connectée, inchangé) ;
|
||||
- ``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``.
|
||||
"""
|
||||
from app import accounts as accounts_service
|
||||
from app import draco
|
||||
|
||||
settings = get_settings()
|
||||
claims = draco.claims_for(request)
|
||||
if not settings.auth_enabled or claims is None:
|
||||
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.
|
||||
current = _current_user(request)
|
||||
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:
|
||||
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)
|
||||
# Compte lié à ce sub : ouvrir sa session.
|
||||
linked = models.get_user_by_draco(sub) if sub else None
|
||||
if linked is not None and linked["active"]:
|
||||
# Correspondance, amorçage ou création JIT : ouvrir la
|
||||
# session du compte que ce sub désigne — et seulement lui.
|
||||
user = accounts_service.resolve_draco_user(claims, provisionner=True)
|
||||
if user is not None:
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_make_session(linked["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),
|
||||
_make_session(user["username"]),
|
||||
max_age=SESSION_DAYS * 86400,
|
||||
httponly=True,
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
async def admin_connections(request: Request, msg: str = "", err: str = ""):
|
||||
admin = _require_admin(request)
|
||||
|
||||
+4
-2
@@ -1012,12 +1012,14 @@ def create_user(
|
||||
role: str = "member",
|
||||
display_name: str = "",
|
||||
must_change_password: int = 0,
|
||||
draco_sub: str | None = None,
|
||||
) -> int:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role, display_name, "
|
||||
"must_change_password) VALUES (?, ?, ?, ?, ?)",
|
||||
(username, password_hash, role, display_name, must_change_password),
|
||||
"must_change_password, draco_sub) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(username, password_hash, role, display_name,
|
||||
must_change_password, draco_sub),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
|
||||
<section class="briefing-card">
|
||||
<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">
|
||||
<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>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
@@ -38,7 +39,22 @@
|
||||
</form>
|
||||
</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 class="admin-actions">
|
||||
<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>
|
||||
</form>
|
||||
{% 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 %}
|
||||
</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
|
||||
(secret partagé, HS256) et converti en session Kanbante native par
|
||||
GET /auth/draco. Kanbante est mono-utilisateur : un jeton valide
|
||||
ouvre la session de l'opérateur configuré. Sans
|
||||
DRACO_SESSION_SECRET, le pont renvoie simplement au login.
|
||||
GET /auth/draco. Depuis la 0.46.0 (HUB-16), la correspondance est
|
||||
par utilisateur (users.draco_sub, création JIT) — voir
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user