From f8f8af63e120ec13b2a7afed8badf48f267f51d1 Mon Sep 17 00:00:00 2001 From: Spark Date: Sat, 7 Nov 2026 04:16:19 +0000 Subject: [PATCH] =?UTF-8?q?Kanbante=200.46.5=20=E2=80=94=20pont=20draco=20?= =?UTF-8?q?:=20adoption=20de=20l'amor=C3=A7age=20conditionn=C3=A9e=20(fusi?= =?UTF-8?q?on=20#145)=20et=20=C3=A9chec=20de=20connexion=20sugg=C3=A9rant?= =?UTF-8?q?=20le=20SSO=20(fusion=20#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 + VERSION | 2 +- app/accounts.py | 42 ++++- app/main.py | 64 +++++-- docs/correspondance-draco.md | 48 +++-- tests/test_amorcage_pont.py | 293 +++++++++++++++++++++++++++++ tests/test_draco_correspondance.py | 19 +- tests/test_draco_sso.py | 9 +- 8 files changed, 449 insertions(+), 38 deletions(-) create mode 100644 tests/test_amorcage_pont.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ce02b75..cf00d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.46.5 — 2026-11-06 — Pont draco : adoption de l'amorçage conditionnée (fusion #145) et échec de connexion qui suggère le SSO (fusion #144, part Kanbante) + +Version **corrective de sécurité** dans la série 0.46.x (base 0.46.4 ; la 0.47.0 de la PR #87, métadonnées des jetons, reste indépendante et non touchée). + +- **Trou corrigé (audit « compte partout » du 2026-11-06)** : sur une instance fraîche, le premier visiteur du pont `/auth/draco` adoptait le compte administrateur d'amorçage — un simple compte `user` de la suite (« dan ») l'a fait en pile de contrôle et est devenu l'admin local. Cause : la « liaison au premier passage » de `resolve_draco_user()` (`app/accounts.py`), commodité de l'ère mono-utilisateur. +- **Nouvelle règle** : l'amorçage (unique compte, admin actif, sans liaison) n'est adopté que si **(a)** le `sub` entrant est « administrateur » pour kanbante **au registre des rôles du Hub** — le pont, qui consultait déjà le registre (HUB-19), transmet désormais le rôle à la résolution — ou **(b)** l'adoption est initiée depuis une **session native déjà authentifiée** (liaison explicite par l'utilisateur connecté : passage du pont avec session native ouverte, ou connexion native doublement connectée — mécanismes existants, inchangés). **Repli propre** : Hub muet, réponse illisible ou rôle moindre → **pas d'adoption** ; tout autre `sub` inconnu reçoit le parcours JIT ordinaire (compte **membre** propre, jamais l'amorçage). La correspondance existante (cas de production : amorçage déjà lié) résout exactement comme avant. `/api/search`, qui ne consulte pas le registre, ne peut plus adopter l'amorçage du tout. +- **Fusion #144 (part Kanbante)** : l'échec du formulaire local ne rend plus le seul « Identifiants incorrects. ». Quand l'identifiant ne correspond à aucun compte local, le message le dit et suggère la voie centrale — « Se connecter avec ObsiGate (SSO) », le bouton déjà présent sur l'écran (libellé inchangé, référence d'harmonisation) ; un compte créé au Hub n'a en effet pas de mot de passe local (il vit chez ObsiGate). Le même renvoi au SSO suit l'échec sur un compte local existant. +- **Documentation** : `docs/correspondance-draco.md` — section « Compte d'amorçage » réécrite (adoption conditionnée a/b, surface `/api/search` sans adoption). +- **Tests** : nouveau `tests/test_amorcage_pont.py` — instance réellement fraîche par test (base temporaire : seul l'amorçage, sans liaison) : (i) `sub` simple utilisateur → JIT membre propre, amorçage intact non lié ; (ii) `sub` administrateur au registre → adoption ; (ii-bis) Hub muet → pas d'adoption ; (iii) liaison existante → résolution inchangée ; (iv) session native authentifiée → liaison explicite (pont et résolution directe) ; échecs de formulaire #144 (identifiant sans compte local, mauvais mot de passe). Le test historique d'amorçage de `tests/test_draco_correspondance.py` simule désormais le rôle « administrateur » du registre, et le docstring de `tests/test_draco_sso.py` est aligné (le premier jeton ne se lie plus à l'amorçage). + ## 0.46.4 — 2026-11-06 — Barre commune v0.6.0 re-vendangée : entrée « Administration » (fusion #142) - **Re-vendange verbatim** de la barre commons v0.6.0 (PR dracodev-commons #14) : `static/js/draco-barre.js` + `static/css/draco-barre.css`. Le menu compte gagne l'entrée **Administration** → `/admin` (le Hub redirige vers `/admin/utilisateurs`), à **visibilité conditionnelle** (affirmée par l'hôte via `cfg.admin` / `DRACO_BAR.setAdmin`, jamais déduite par la barre). diff --git a/VERSION b/VERSION index fadda05..992b0b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.4 +0.46.5 diff --git a/app/accounts.py b/app/accounts.py index fd6bbab..f8bbf5f 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -79,13 +79,26 @@ def ensure_bootstrap_admin() -> dict | None: # (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 ; +# 2. amorçage CONDITIONNÉ (fusion #145, 0.46.5) : s'il n'existe +# qu'UN seul compte et que c'est un admin actif sans liaison, +# ce compte d'amorçage ne peut être adopté que si +# a) le sub entrant est « administrateur » pour kanbante +# au registre des rôles du Hub (app.hub_roles, rôle +# transmis par l'appelant via ``role_hub`` — dans le +# doute, registre muet ou rôle moindre, PAS d'adoption), +# ou b) l'adoption est initiée depuis une session native +# déjà authentifiée (``liaison_session_native=True`` : +# l'utilisateur connecté lie explicitement son compte) ; +# tout autre sub inconnu suit le parcours ordinaire (JIT ou +# refus selon la surface), JAMAIS l'amorçage ; # 3. sinon, si provisionner=True : création JIT d'un compte # propre à cette identité (voir provision_draco_user). # +# Avant la 0.46.5, l'étape 2 liait l'amorçage au PREMIER sub +# valide de passage : sur une instance fraîche, un simple +# utilisateur de la suite devenait l'administrateur local +# (audit « compte partout » du 2026-11-06, carte fusion #145). +# # 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 @@ -163,7 +176,12 @@ def provision_draco_user(sub: str, nom_affiche: str = "") -> dict | None: return models.get_user(user_id) -def resolve_draco_user(claims: dict | None, provisionner: bool = False) -> dict | None: +def resolve_draco_user( + claims: dict | None, + provisionner: bool = False, + role_hub: str | None = None, + liaison_session_native: bool = False, +) -> dict | None: """Compte Kanbante désigné par des claims draco valides. provisionner=False : correspondance et amorçage seulement @@ -173,6 +191,15 @@ def resolve_draco_user(claims: dict | None, provisionner: bool = False) -> dict 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). + + Adoption de l'amorçage (fusion #145) : ``role_hub`` est le + rôle du sub au registre du Hub pour kanbante, déjà consulté + par l'appelant (app.hub_roles ; ``None`` = inconnu, Hub + muet) et ``liaison_session_native`` signale une adoption + initiée depuis une session native authentifiée. L'amorçage + n'est adopté que si l'un des deux vaut respectivement + ``"administrateur"`` / ``True`` — sinon, aucun paramètre ne + doit être deviné ici : dans le doute, pas d'adoption. """ sub = str((claims or {}).get("sub") or "") if not sub: @@ -186,8 +213,11 @@ def resolve_draco_user(claims: dict | None, provisionner: bool = False) -> dict seul["role"] == "admin" and seul["active"] and not seul.get("draco_sub") + and (role_hub == "administrateur" or liaison_session_native) ): - # Compte d'amorçage : liaison au premier passage. + # Compte d'amorçage : adoption conditionnée (#145) — + # administrateur au registre du Hub, ou liaison + # explicite depuis une session native authentifiée. models.update_user(seul["id"], draco_sub=sub) models.log_admin(seul["id"], "draco.link.amorcage", sub) return models.get_user(seul["id"]) diff --git a/app/main.py b/app/main.py index b795f10..9cbd929 100644 --- a/app/main.py +++ b/app/main.py @@ -432,8 +432,8 @@ class AuthMiddleware(BaseHTTPMiddleware): # Recherche partout (#68) : /api/search accepte aussi le jeton # 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 + # (correspondance existante, 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. @@ -576,9 +576,33 @@ async def login_submit( if ok and user is not None and user["active"]: models.log_admin(user["id"], "login.env_fallback", username) if not ok: + # Fusion #144 (part Kanbante) : l'échec ne doit plus être + # une impasse muette pour un compte de la suite. Un compte + # créé au Hub n'a pas de mot de passe local ici (il vit + # chez ObsiGate) : le message suggère la voie centrale — + # le bouton « Se connecter avec ObsiGate (SSO) » présent + # sur ce même écran — au lieu du seul refus sec. Quand + # l'identifiant ne correspond à aucun compte local (et + # n'est pas l'opérateur d'environnement), le message le + # dit explicitement : c'est le cas typique du compte + # centralisé essayé dans le formulaire local. + if user is None and username != settings.auth_user: + erreur = ( + "Identifiants incorrects — aucun compte local ne " + "correspond à cet identifiant. Si ton compte a été " + "créé dans la suite Dracodev (au Hub), il n'a pas " + "de mot de passe local ici : utilise « Se connecter " + "avec ObsiGate (SSO) » ci-dessous." + ) + else: + erreur = ( + "Identifiants incorrects. Si tu te connectes avec " + "un compte de la suite Dracodev, utilise plutôt " + "« Se connecter avec ObsiGate (SSO) » ci-dessous." + ) return render( request, "login.html", status_code=401, - next=_safe_next(next), error="Identifiants incorrects.", + next=_safe_next(next), error=erreur, ) if user is not None: models.update_user( @@ -587,8 +611,10 @@ async def login_submit( "%Y-%m-%d %H:%M:%S", time.gmtime() ), ) - # First doubly-connected visit: link the draco identity - # to this account (same mechanics as the other apps). + # Première visite doublement connectée : la session + # native vient d'être authentifiée par mot de passe — + # lier l'identité draco à ce compte est la voie (b) de + # liaison explicite de l'amorçage (fusion #145). from app import draco claims = draco.claims_for(request) @@ -624,11 +650,15 @@ def draco_bridge(request: Request, next: str = "/"): 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é) ; + ce compte (première visite doublement connectée, inchangé) — + c'est la voie (b) de la liaison explicite de l'amorçage ; - ``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 ; + adoption CONDITIONNÉE (fusion #145, 0.46.5) — seulement si + le registre du Hub donne « administrateur » pour kanbante + à ce ``sub`` (rôle consulté ci-dessous et transmis à la + résolution) ; dans le doute (Hub muet, rôle moindre), PAS + d'adoption : le parcours ordinaire suit, ci-dessous ; - 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. @@ -682,6 +712,12 @@ def draco_bridge(request: Request, next: str = "/"): ), ) # Déjà connecté nativement : lier le sub à ce compte. + # C'est la voie (b) de l'adoption de l'amorçage (fusion + # #145) : la liaison est initiée par l'utilisateur déjà + # authentifié en session native, qui connaît donc les + # identifiants du compte qu'il lie — aucun rôle du registre + # n'est exigé pour CE geste explicite (le refus « aucun » + # ci-dessus continue de le précéder, patron HUB-19). current = _current_user(request) if current is not None and current.get("id") is not None: if not current.get("draco_sub"): @@ -689,12 +725,18 @@ def draco_bridge(request: Request, next: str = "/"): models.update_user(current["id"], draco_sub=sub) models.log_admin(current["id"], "draco.link", sub) return RedirectResponse(_safe_next(next), status_code=303) - # Correspondance, amorçage ou création JIT : ouvrir la - # session du compte que ce sub désigne — et seulement lui. + # Correspondance, amorçage conditionné ou création JIT : + # ouvrir la session du compte que ce sub désigne — et + # seulement lui. Le rôle du registre consulté ci-dessus est + # transmis à la résolution : c'est lui (et lui seul, hors + # session native déjà traitée plus haut) qui peut autoriser + # l'adoption du compte d'amorçage (fusion #145). # La session est marquée « établie par draco » (segment signé, # voir _make_session) : c'est elle, et elle seule, que le # garde de /admin soumettra au registre du Hub. - user = accounts_service.resolve_draco_user(claims, provisionner=True) + user = accounts_service.resolve_draco_user( + claims, provisionner=True, role_hub=role_hub + ) if user is not None: response = RedirectResponse(_safe_next(next), status_code=303) response.set_cookie( diff --git a/docs/correspondance-draco.md b/docs/correspondance-draco.md index 0a1a256..c47a19d 100644 --- a/docs/correspondance-draco.md +++ b/docs/correspondance-draco.md @@ -36,16 +36,32 @@ Dans l'ordre : 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. +3. **Compte d'amorçage — adoption conditionnée (fusion #145, + 0.46.5)** : s'il n'existe qu'**un seul compte** et que c'est un + **admin actif sans liaison**, ce compte (celui créé à + l'installation depuis l'environnement `KANBANTE_AUTH_USER` / + `KANBANTE_AUTH_PASSWORD`, Bruno) n'est adopté que dans l'un + de ces deux cas : + - **(a)** le `sub` entrant est **« administrateur » pour + kanbante au registre des rôles du Hub** (le pont consulte le + registre avant la résolution et transmet le rôle à + `resolve_draco_user()` ; Hub muet ou rôle moindre = **pas + d'adoption**, dans le doute on ne lie pas) ; + - **(b)** l'adoption est initiée depuis une **session native + déjà authentifiée** (point 1 ci-dessus, ou connexion native + doublement connectée) : l'utilisateur connecté lie + explicitement son propre compte. + Tout autre `sub` inconnu reçoit le parcours ordinaire + (création JIT d'un compte **membre** propre, point 4) — + jamais l'amorçage. Avant la 0.46.5, le premier `sub` valide + de passage adoptait l'amorçage quel qu'il soit : sur une + instance fraîche, un simple utilisateur de la suite devenait + l'administrateur local (audit « compte partout » du + 2026-11-06). Les autres accès de l'amorçage 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. @@ -139,11 +155,13 @@ internes ni la réconciliation par étiquettes ne changent. - **`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. + `sub` désigne un compte (correspondance existante). Cette + surface ne consulte pas le registre et n'initie aucune liaison : + elle ne peut donc **jamais adopter le compte d'amorçage** + (fusion #145) — l'adoption a lieu au pont, ou pas. 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 diff --git a/tests/test_amorcage_pont.py b/tests/test_amorcage_pont.py new file mode 100644 index 0000000..78532d8 --- /dev/null +++ b/tests/test_amorcage_pont.py @@ -0,0 +1,293 @@ +"""Fusion #145 (0.46.5) — adoption CONDITIONNÉE du compte d'amorçage. + +Avant ce correctif, sur une instance fraîche, le premier visiteur +du pont draco adoptait le compte administrateur d'amorçage +(« liaison au premier passage ») — un simple utilisateur de la +suite (« dan », audit « compte partout » du 2026-11-06) l'a fait +en pile de contrôle. Désormais, l'adoption n'a lieu que si : + + (a) le ``sub`` entrant est « administrateur » pour kanbante au + registre des rôles du Hub (repli propre : Hub muet = rôle + inconnu = PAS d'adoption) ; + (b) ou l'adoption est initiée depuis une session native déjà + authentifiée (liaison explicite par l'utilisateur connecté). + +Couvert ici, sur instance RÉELLEMENT fraîche (base SQLite +temporaire propre à chaque test : au démarrage, seul le compte +d'amorçage créé depuis l'environnement existe, sans liaison) : + + (i) sub simple utilisateur → compte membre JIT propre, + amorçage intact et toujours sans liaison ; + (ii) sub administrateur au registre → adoption de l'amorçage ; + (ii-bis) Hub muet (rôle inconnu) → pas d'adoption, JIT membre ; + (iii) liaison existante (cas de production) → résolution + inchangée, quel que soit le rôle du registre ; + (iv) session native authentifiée → liaison explicite de + l'amorçage possible (pont et résolution directe) ; + +plus la part Kanbante de la fusion #144 : l'échec du formulaire +local suggère la voie « Se connecter avec ObsiGate (SSO) ». + +Le faux Hub suit le patron de tests/test_hub_roles.py : l'appel +``httpx.get`` du client du registre est simulé, les rôles sont +lus dans le cookie relayé. +""" + +import base64 +import hashlib +import hmac +import json +import time + +import httpx +import pytest +from fastapi.testclient import TestClient + +import app.main as main +from app import accounts, draco, hub_roles, models + +SECRET = "secret-amorcage-145" + +SUB_SIMPLE = "fus145-simple" +SUB_ADMIN = "fus145-admin" +SUB_INCONNU = "fus145-inconnu" +SUB_LIE = "fus145-deja-lie" +SUB_NATIF = "fus145-natif" +SUB_NATIF_DIRECT = "fus145-natif-direct" + + +def _jeton(sub, nom="Visiteur 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 _sub_depuis_cookie(cookie: str) -> str: + for morceau in (cookie or "").split(";"): + nom, _, valeur = morceau.strip().partition("=") + if nom == "draco_session" and valeur.count(".") == 2: + segment = valeur.split(".")[1] + segment += "=" * (-len(segment) % 4) + try: + return json.loads(base64.urlsafe_b64decode(segment))["sub"] + except Exception: + return "" + return "" + + +class _Reponse: + def __init__(self, status_code=200, donnees=None): + self.status_code = status_code + self._donnees = donnees + + def json(self): + return self._donnees + + +@pytest.fixture(autouse=True) +def _cache_propre(): + hub_roles._reset_cache() + yield + hub_roles._reset_cache() + + +@pytest.fixture() +def faux_hub(monkeypatch): + """Faux registre du Hub : rôles par ``sub``, lus dans le cookie relayé.""" + etat = {"roles": {}, "mode": "ok"} + + def faux_get(url, headers=None, timeout=None): + if etat["mode"] == "panne": + raise httpx.ConnectError("Hub injoignable (simulation)") + sub = _sub_depuis_cookie((headers or {}).get("Cookie", "")) + role = etat["roles"].get(sub, "utilisateur") + return _Reponse(200, { + "app": "kanbante", "sub": sub, "role": role, + "explicite": True, "ttl_secondes": 60, + }) + + monkeypatch.setattr(httpx, "get", faux_get) + return etat + + +@pytest.fixture() +def instance_fraiche(monkeypatch, tmp_path, fake_gitea, faux_hub): + """Instance neuve : base temporaire, amorçage seul et non lié. + + Au démarrage (TestClient), ``ensure_bootstrap_admin`` crée + l'unique compte « proprio » (admin) depuis l'environnement — + c'est exactement l'état de l'audit : un seul compte, aucune + liaison draco. + """ + monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "fraiche.db")) + monkeypatch.setenv("KANBANTE_AUTH_USER", "proprio") + monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "mdp-amorce") + monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET) + monkeypatch.setattr(draco, "_refresh_revocations", lambda: None) + draco._revoked_cache.clear() + with TestClient(main.app) as client: + users = models.list_users() + assert len(users) == 1, users + assert users[0]["username"] == "proprio" + assert users[0]["role"] == "admin" + assert not users[0].get("draco_sub") + yield client, faux_hub, users[0] + draco._revoked_cache.clear() + + +def _pont(client, sub, nom="Visiteur 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 + return r.text.split("page-sub\">", 1)[1].split(" ·", 1)[0].strip() + + +def _csrf_via_login(client) -> str: + client.get("/login") + return client.cookies.get("kanbante_csrf") or "" + + +# ── (i) Simple utilisateur : JIT membre, amorçage intact ───── + +def test_simple_utilisateur_jit_membre_amorcage_intact(instance_fraiche): + client, hub, amorce = instance_fraiche + hub["roles"][SUB_SIMPLE] = "utilisateur" + r = _pont(client, SUB_SIMPLE, "Dan Simple") + assert r.status_code == 303 and r.headers["location"] == "/" + assert "kanbante_session" in r.headers.get("set-cookie", "") + # L'amorçage est intact : toujours là, admin, TOUJOURS sans liaison. + apres = models.get_user(amorce["id"]) + assert apres["role"] == "admin" + assert not apres.get("draco_sub") + # Le visiteur a reçu SON compte : membre, distinct, lié à son sub. + cree = models.get_user_by_draco(SUB_SIMPLE) + assert cree is not None and cree["id"] != amorce["id"] + assert cree["role"] == "member" + assert models.count_users() == 2 + assert _profil_username(client) == cree["username"] + + +# ── (ii) Administrateur au registre : adoption ─────────────── + +def test_administrateur_registre_adopte_amorcage(instance_fraiche): + client, hub, amorce = instance_fraiche + hub["roles"][SUB_ADMIN] = "administrateur" + r = _pont(client, SUB_ADMIN, "Chef Suite") + assert r.status_code == 303 and r.headers["location"] == "/" + # Aucun compte créé : le sub s'est lié au compte d'amorçage. + assert models.count_users() == 1 + lie = models.get_user_by_draco(SUB_ADMIN) + assert lie is not None and lie["id"] == amorce["id"] + assert lie["role"] == "admin" + assert _profil_username(client) == "proprio" + + +def test_hub_muet_pas_d_adoption(instance_fraiche): + """Repli propre : rôle inconnu (Hub injoignable) = pas d'adoption.""" + client, hub, amorce = instance_fraiche + hub["mode"] = "panne" + r = _pont(client, SUB_INCONNU, "Inconnu Exemple") + assert r.status_code == 303 and r.headers["location"] == "/" + assert not models.get_user(amorce["id"]).get("draco_sub") + cree = models.get_user_by_draco(SUB_INCONNU) + assert cree is not None and cree["role"] == "member" + assert cree["id"] != amorce["id"] + + +# ── (iii) Liaison existante : résolution inchangée ─────────── + +def test_liaison_existante_resolution_inchangee(instance_fraiche): + client, hub, amorce = instance_fraiche + # Cas de production : l'amorçage est DÉJÀ lié (à Bruno). + models.update_user(amorce["id"], draco_sub=SUB_LIE) + # Même avec un rôle moindre au registre, la correspondance + # existante désigne son compte, comme avant le correctif. + hub["roles"][SUB_LIE] = "utilisateur" + claims = {"sub": SUB_LIE, "name": "Bruno"} + resolu = accounts.resolve_draco_user(claims) + assert resolu is not None and resolu["id"] == amorce["id"] + r = _pont(client, SUB_LIE, "Bruno") + assert r.status_code == 303 and r.headers["location"] == "/" + assert models.count_users() == 1 + assert _profil_username(client) == "proprio" + + +# ── (iv) Session native authentifiée : liaison explicite ───── + +def test_session_native_lie_explicitement_via_le_pont(instance_fraiche): + client, hub, amorce = instance_fraiche + hub["roles"][SUB_NATIF] = "utilisateur" + # Connexion native de l'amorçage (mot de passe du compte). + token = _csrf_via_login(client) + r = client.post( + "/login", + data={"username": "proprio", "password": "mdp-amorce", + "next": "/", "csrf_token": token}, + follow_redirects=False, + ) + assert r.status_code == 303 + assert "kanbante_session" in r.headers.get("set-cookie", "") + # Le passage du pont, session native ouverte, lie le sub libre + # au compte de la session — sans rôle administrateur au registre. + r = _pont(client, SUB_NATIF, "Proprio Lui-Même") + assert r.status_code == 303 + lie = models.get_user_by_draco(SUB_NATIF) + assert lie is not None and lie["id"] == amorce["id"] + assert models.count_users() == 1 + + +def test_resolution_directe_avec_session_native(instance_fraiche): + _client, _hub, amorce = instance_fraiche + claims = {"sub": SUB_NATIF_DIRECT, "name": "Proprio"} + # Sans drapeau : aucune adoption (résolution « à froid »). + assert accounts.resolve_draco_user(claims) is None + assert not models.get_user(amorce["id"]).get("draco_sub") + # Avec le drapeau « session native authentifiée » : adoption. + resolu = accounts.resolve_draco_user( + claims, liaison_session_native=True + ) + assert resolu is not None and resolu["id"] == amorce["id"] + assert models.get_user_by_draco(SUB_NATIF_DIRECT)["id"] == amorce["id"] + + +# ── Fusion #144 : l'échec du formulaire suggère le SSO ─────── + +def test_echec_login_identifiant_sans_compte_local_suggere_sso(instance_fraiche): + client, _hub, _amorce = instance_fraiche + token = _csrf_via_login(client) + r = client.post( + "/login", + data={"username": "compte-du-hub", "password": "peu-importe", + "next": "/", "csrf_token": token}, + follow_redirects=False, + ) + assert r.status_code == 401 + assert "aucun compte local" in r.text + assert "Se connecter avec ObsiGate (SSO)" in r.text + assert "kanbante_session" not in r.headers.get("set-cookie", "") + + +def test_echec_login_mauvais_mot_de_passe_suggere_sso(instance_fraiche): + client, _hub, _amorce = instance_fraiche + token = _csrf_via_login(client) + r = client.post( + "/login", + data={"username": "proprio", "password": "mauvais", + "next": "/", "csrf_token": token}, + follow_redirects=False, + ) + assert r.status_code == 401 + assert "Identifiants incorrects" in r.text + assert "Se connecter avec ObsiGate (SSO)" in r.text diff --git a/tests/test_draco_correspondance.py b/tests/test_draco_correspondance.py index 2460dd4..f8b9eb5 100644 --- a/tests/test_draco_correspondance.py +++ b/tests/test_draco_correspondance.py @@ -4,8 +4,11 @@ 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) ; +- amorçage : l'unique compte admin sans liaison ne se lie plus + au premier passage — depuis la 0.46.5 (fusion #145), il faut + un rôle « administrateur » au registre du Hub (voir + tests/test_amorcage_pont.py pour la matrice complète) ; le + cas historique est couvert ici avec ce rôle simulé ; - 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 @@ -129,7 +132,17 @@ def _unique_admin_sans_liaison(): # ── Amorçage ────────────────────────────────────────────────── -def test_amorcage_unique_admin_se_lie_au_premier_passage(client_hub16): +def test_amorcage_unique_admin_se_lie_au_premier_passage(client_hub16, monkeypatch): + # Depuis la 0.46.5 (fusion #145), le premier passage ne lie + # l'amorçage que pour un « administrateur » du registre du + # Hub : c'est ce rôle qui est simulé ici. Sans lui, le même + # passage crée un compte membre JIT (test_amorcage_pont.py). + from app import hub_roles + + monkeypatch.setattr( + hub_roles, "role_pour", + lambda cookie, sub, hote=None: "administrateur", + ) 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") diff --git a/tests/test_draco_sso.py b/tests/test_draco_sso.py index 8c13ec3..8b4c643 100644 --- a/tests/test_draco_sso.py +++ b/tests/test_draco_sso.py @@ -5,8 +5,13 @@ Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement 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é). +valides : dans ce module, le seul compte préexistant est l'admin +d'amorçage ; depuis la 0.46.5 (fusion #145), le premier jeton +valide ne s'y lie PLUS automatiquement (adoption conditionnée au +rôle « administrateur » du registre du Hub ou à une session +native) — le pont ouvre alors la session d'un compte JIT propre +à l'identité, ce que ces cas vérifient (session ouverte, accès +à l'app) sans présumer du compte résolu. Sans DRACO_SESSION_SECRET, le pont renvoie simplement au login. """ -- 2.54.0