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)
This commit is contained in:
@@ -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).
|
||||
|
||||
+36
-6
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user