Kanbante 0.46.5 — pont draco : adoption de l'amorçage conditionnée (fusion #145) et échec de connexion suggérant le SSO (fusion #144)
CI / test (push) Failing after 2m26s
CI / test (pull_request) Failing after 2m35s

This commit is contained in:
Spark
2026-11-07 04:16:19 +00:00
parent d89b35061b
commit f8f8af63e1
8 changed files with 449 additions and 38 deletions
+10
View File
@@ -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** → `<hub>/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).
+1 -1
View File
@@ -1 +1 @@
0.46.4
0.46.5
+36 -6
View File
@@ -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"])
+53 -11
View File
@@ -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(
+33 -15
View File
@@ -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
+293
View File
@@ -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
+16 -3
View File
@@ -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")
+7 -2
View File
@@ -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.
"""