diff --git a/CHANGELOG.md b/CHANGELOG.md index def63f6..36d07b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é. diff --git a/VERSION b/VERSION index cbb488e..3010923 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.12 +0.46.0 diff --git a/app/accounts.py b/app/accounts.py index 74a2d6a..fd6bbab 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -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- ». 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 diff --git a/app/draco.py b/app/draco.py index 5ede1d1..3325a14 100644 --- a/app/draco.py +++ b/app/draco.py @@ -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. """ diff --git a/app/main.py b/app/main.py index 57d47c0..4d7fbb3 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py index 503190d..f3694a5 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/templates/admin.html b/app/templates/admin.html index 357ab14..d4e4777 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -20,9 +20,10 @@

Utilisateurs ({{ users|length }})

+

Correspondance draco (SSO, HUB-16) : chaque identité draco (sub) désigne son compte Kanbante. Un compte de suite sans correspondance est créé automatiquement à sa première connexion draco (JIT, rôle membre). Pour un compte Kanbante préexistant, c'est ici que l'administrateur établit la liaison : saisir le sub draco du compte puis « Lier » (champ affiché = délier). Un sub ne peut être lié qu'à un seul compte. Procédure complète : docs/correspondance-draco.md.

- + {% for u in users %} @@ -38,7 +39,22 @@ - +
UtilisateurRôleÉtatDracoDernière connexionActions
UtilisateurRôleÉtatDraco (sub)Dernière connexionActions
{% if u.active %}Actif{% else %}Désactivé{% endif %}{% if u.draco_sub %}lié{% else %}—{% endif %} + {% if u.draco_sub %} + {{ u.draco_sub }} +
+ + + +
+ {% else %} +
+ + + +
+ {% endif %} +
{{ u.last_login_at or '—' }}
diff --git a/app/templates/profile.html b/app/templates/profile.html index af9001f..9db860b 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -92,7 +92,7 @@
{% else %} -

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).

+

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).

{% endif %} diff --git a/docs/correspondance-draco.md b/docs/correspondance-draco.md new file mode 100644 index 0000000..6415a04 --- /dev/null +++ b/docs/correspondance-draco.md @@ -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-` 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. diff --git a/tests/test_draco_correspondance.py b/tests/test_draco_correspondance.py new file mode 100644 index 0000000..2460dd4 --- /dev/null +++ b/tests/test_draco_correspondance.py @@ -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 + # « · 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" diff --git a/tests/test_draco_sso.py b/tests/test_draco_sso.py index 38930b9..8c13ec3 100644 --- a/tests/test_draco_sso.py +++ b/tests/test_draco_sso.py @@ -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