From 296d9cfa25747a9b455900dd8d97910f3c7c2621 Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 6 Nov 2026 14:03:23 +0000 Subject: [PATCH] =?UTF-8?q?Flowdeck=207.85.0=20=E2=80=94=20HUB-15=20:=20pr?= =?UTF-8?q?ovisionnement=20JIT=20des=20comptes=20draco=20(fusion=20#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Généralisation de maybe_link : à la première connexion draco, si aucun compte local ne correspond au sub (ni lien draco_links, ni repli mono-utilisateur), le pendant local est créé depuis les claims (sub, name) et lié dans la même transaction. Rôle par défaut sûr : utilisateur standard (is_admin=0), auth_method 'draco', password_hash NULL (aucune connexion locale par mot de passe pour ce compte ; inchangée pour les autres). Garde pierre tombale : une ligne draco_links existante interdit toute création. Connexion locale et OAuth Gitea/GitHub intacts. Tests : test_fusion_draco.py étendu (création JIT, pierre tombale) ; 9/9 sur le fichier, balayage auth 226 réussis (3 échecs préexistants sur main vierge, réseau). --- CHANGELOG.md | 8 ++++ VERSION | 2 +- app/main.py | 2 +- app/services/draco.py | 86 +++++++++++++++++++++++++++++++++++++- tests/test_fusion_draco.py | 80 +++++++++++++++++++++++++++++++---- 5 files changed, 167 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ac5c5..3ba20b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## v7.85.0 (2026-11-06) — HUB-15 : provisionnement JIT des comptes draco (fusion #129) + +- **Généralisation de `maybe_link` en création JIT** (`app/services/draco.py`) : à la première connexion avec un cookie `draco_session` valide, si aucun compte local ne correspond au `sub` (ni lien `draco_links`, ni repli mono-utilisateur), le pendant local est **créé automatiquement** depuis les claims (`sub`, `name`) et lié au `sub` dans la même transaction — aucune création manuelle dans l'app. Un compte de suite créé dans le Hub fonctionne donc dans Flowdeck dès sa première visite. +- **Rôle par défaut sûr** : utilisateur standard (`is_admin = 0`, jamais admin), `auth_method = 'draco'`, **aucun mot de passe local** (`password_hash` NULL — la connexion locale par mot de passe reste impossible pour ce compte, et inchangée pour tous les autres). Login déterministe `draco-` (suffixe d'empreinte en cas de collision avec un login existant non lié). +- **Gardes** : une ligne `draco_links` existante pour le `sub` (lien actif, compte inactif ou **pierre tombale** de dissociation) interdit toute création — une dissociation explicite n'est jamais contournée. Les comptes déjà liés ne changent pas ; la connexion locale et l'OAuth Gitea/GitHub sont intacts. +- **Numérotation** : fonction nouvelle → série mineure 7.84 → **7.85.0** (patron du dépôt : la série porte les vagues de fonctions, les correctifs restent en patch). +- Tests : `tests/test_fusion_draco.py` — création JIT en multi-utilisateurs (rôle, absence de mot de passe, lien, réutilisation à la 2e visite, connexion locale refusée), pierre tombale sans re-création ; l'ancien refus « lien requis en multi-utilisateurs » est remplacé par le nouveau contrat (le lien explicite continue d'ouvrir la porte). + ## v7.84.15 (2026-11-05) — Barre commune v0.4.1 : correctif des icônes invisibles du menu compte (fusion #125) - **Barre d'apps v0.4.1 vendangée (dracodev-commons, branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11)** : seul `static/js/vendor/draco-barre.js` change, remplacé verbatim par la v0.4.1 ; le CSS et le fragment `app/templates/_draco_bar.html` sont inchangés. diff --git a/VERSION b/VERSION index 009f75c..667c13b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.84.15 +7.85.0 diff --git a/app/main.py b/app/main.py index 0389a1e..fd2a800 100644 --- a/app/main.py +++ b/app/main.py @@ -216,7 +216,7 @@ def _lire_version() -> str: # Littéral de version synchronisé avec le fichier VERSION : -# version="7.84.3" +# version="7.85.0" # Patron historique des bumps (voir scripts/bump_version.py, qui cherche # ce littéral) et contrôle de tests/test_pwa_offline.py. La version # runtime reste lue dynamiquement depuis VERSION par _lire_version() diff --git a/app/services/draco.py b/app/services/draco.py index e03877c..5ddc1b5 100644 --- a/app/services/draco.py +++ b/app/services/draco.py @@ -15,6 +15,17 @@ local : table ``draco_links`` (migration 51), créée automatiquement coexistent (association 1:1, dissociation = pierre tombale) ; à défaut de lien, une instance à un seul utilisateur actif associe ce sub à cet unique compte. Sans secret configuré, module inerte. + +HUB-15 (plan Hub v2.1, §2.2) — provisionnement JIT : si aucun compte +local ne correspond au ``sub`` (ni lien, ni repli mono-utilisateur), +le pendant local est **créé automatiquement** à la première +connexion draco, depuis les claims (``sub``, ``name``), avec le rôle +par défaut sûr : utilisateur standard (``is_admin = 0``, jamais +admin), aucun mot de passe local (``password_hash`` NULL, +``auth_method = 'draco'`` — l'authentification passe par draco, la +connexion locale par mot de passe reste impossible pour ce compte +et inchangée pour les autres). Une pierre tombale (dissociation +explicite) interdit toute re-création automatique pour ce ``sub``. """ from __future__ import annotations @@ -138,8 +149,79 @@ def _linked_user(conn, sub: str) -> dict | None: return None +def _jit_login(sub: str) -> str: + """Login local déterministe dérivé du ``sub`` draco. + + Préfixe ``draco-`` (aucune collision possible avec un login + choisi par un humain ou par l'OAuth, préfixés par fournisseur), + caractères restreints à ``[a-z0-9._-]``. + """ + nettoye = "".join( + c if (c.isascii() and (c.isalnum() or c in "._-")) else "-" + for c in sub.strip().lower() + ).strip("-") + return f"draco-{nettoye}" if nettoye else "draco-compte" + + +def _provision_jit(conn, claims: dict) -> dict | None: + """Crée le pendant local d'un ``sub`` draco inconnu (HUB-15). + + Création + liaison dans la même transaction. Retourne le nouvel + utilisateur, ou None si la création ne doit pas avoir lieu : + une ligne ``draco_links`` existe déjà pour ce ``sub`` (lien actif + — déjà résolu par ``_linked_user`` —, compte lié inactif, ou + pierre tombale d'une dissociation explicite : on ne recrée + jamais automatiquement derrière une dissociation). + """ + sub = str(claims.get("sub", "")) + if not sub: + return None + deja = conn.execute( + "SELECT user_id FROM draco_links WHERE sub = ?", (sub,) + ).fetchone() + if deja is not None: + return None + nom = str(claims.get("name", "") or "").strip() + login = _jit_login(sub) + # Collision résiduelle : un login identique pris par un autre + # compte (non lié) — on suffixe par une empreinte courte du sub + # plutôt que de lier ou d'écraser quoi que ce soit. + if conn.execute( + "SELECT id FROM users WHERE login = ?", (login,) + ).fetchone() is not None: + suffixe = hashlib.sha256(sub.encode("utf-8")).hexdigest()[:8] + login = f"{login}-{suffixe}" + try: + cur = conn.execute( + "INSERT INTO users (login, full_name, email, password_hash, " + "is_admin, is_active, auth_method) " + "VALUES (?, ?, '', NULL, 0, 1, 'draco')", + (login, nom or login), + ) + conn.execute( + "INSERT INTO draco_links (sub, user_id) VALUES (?, ?)", + (sub, cur.lastrowid), + ) + conn.commit() + except Exception as exc: # la création ne casse jamais la requête + logger.debug("Provisionnement JIT draco refusé: %s", exc) + conn.rollback() + return None + row = conn.execute( + "SELECT * FROM users WHERE id = ?", (cur.lastrowid,) + ).fetchone() + logger.info("Compte local créé en JIT pour le sub draco %s (login %s)", + sub, login) + return dict(row) if row is not None else None + + def user_for_draco(request) -> dict | None: - """Utilisateur Flowdeck prouvé par le cookie draco_session, ou None.""" + """Utilisateur Flowdeck prouvé par le cookie draco_session, ou None. + + Résolution : lien ``draco_links`` existant, sinon repli + mono-utilisateur, sinon provisionnement JIT (HUB-15) — le + pendant local est créé depuis les claims et lié au ``sub``. + """ if not enabled(): return None claims = verify_token(request.cookies.get(COOKIE_NAME, "")) @@ -153,6 +235,8 @@ def user_for_draco(request) -> dict | None: return None with get_conn() as conn: user = _linked_user(conn, sub) + if user is None: + user = _provision_jit(conn, claims) if user is not None and not user.get("is_active"): return None return user diff --git a/tests/test_fusion_draco.py b/tests/test_fusion_draco.py index bf234d7..cf9a5d0 100644 --- a/tests/test_fusion_draco.py +++ b/tests/test_fusion_draco.py @@ -4,7 +4,10 @@ Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement (secret partagé, HS256), résolu vers le compte Flowdeck (draco_links, migration 51 ; repli mono-utilisateur), et converti en session native par GET /auth/draco. Sans DRACO_SESSION_SECRET, le -pont renvoie simplement à la connexion locale. +pont renvoie simplement à la connexion locale. HUB-15 ajoute le +provisionnement JIT : aucun compte correspondant → le pendant local +est créé depuis les claims (rôle standard, jamais admin, sans mot +de passe local), sauf pierre tombale de dissociation. """ import base64 @@ -21,13 +24,13 @@ from app.services import draco SECRET = "secret-partage-de-test" -def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test"): +def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test", name="Bruno"): 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": "Bruno", "iat": int(time.time()), + "sub": sub, "name": name, "iat": int(time.time()), "exp": exp if exp is not None else int(time.time()) + 3600, "jti": jti, }).encode()) @@ -101,13 +104,74 @@ def test_lien_requis_en_multi_utilisateurs(client): "INSERT INTO users (login, email, password_hash, full_name, is_active) " "VALUES ('deuxieme', 'deux@example.com', 'x', 'Deuxième', 1)") conn.commit() - # Sans lien et avec deux utilisateurs actifs : le pont refuse. - client.cookies.set("draco_session", _jeton(sub="og-inconnu")) - r = client.get("/auth/draco", follow_redirects=False) - assert r.headers["location"] == "/auth/login?provider=local" - # Le lien explicite (créé pendant une visite connectée) rouvre la porte. + # Le lien explicite (créé pendant une visite connectée) ouvre la porte. client.cookies.set("draco_session", _jeton(sub="og-bruno-1")) assert client.post("/auth/draco-link").json()["sub"] == "og-bruno-1" client.cookies.delete("flowdeck_session") r = client.get("/auth/draco", follow_redirects=False) assert r.headers["location"] == "/workspaces" + + +# ── HUB-15 : provisionnement JIT ───────────────────────────────────── + +def _ajoute_deuxieme_utilisateur(): + with get_conn() as conn: + conn.execute( + "INSERT INTO users (login, email, password_hash, full_name, is_active) " + "VALUES ('deuxieme', 'deux@example.com', 'x', 'Deuxième', 1)") + conn.commit() + + +def _compte_utilisateurs(): + with get_conn() as conn: + return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + + +def test_jit_cree_le_pendant_local_en_multi_utilisateurs(client): + _ajoute_deuxieme_utilisateur() + avant = _compte_utilisateurs() + # Sub inconnu, sans lien, en multi-utilisateurs : avant HUB-15 le + # pont refusait ; désormais le pendant local est créé en JIT. + client.cookies.set("draco_session", _jeton(sub="og-nouveau", name="Nouvelle")) + r = client.get("/auth/draco", follow_redirects=False) + assert r.headers["location"] == "/workspaces" + assert _compte_utilisateurs() == avant + 1 + with get_conn() as conn: + user = conn.execute( + "SELECT * FROM users WHERE login = 'draco-og-nouveau'").fetchone() + assert user is not None + assert user["full_name"] == "Nouvelle" + assert user["is_admin"] == 0 # jamais admin + assert user["is_active"] == 1 + assert user["password_hash"] is None # aucun mot de passe local + assert user["auth_method"] == "draco" + lien = conn.execute( + "SELECT * FROM draco_links WHERE sub = 'og-nouveau'").fetchone() + assert lien is not None and lien["user_id"] == user["id"] + assert lien["unlinked_at"] is None + # Deuxième arrivée : même compte réutilisé, aucune création de plus. + client.cookies.delete("flowdeck_session") + r = client.get("/auth/draco", follow_redirects=False) + assert r.headers["location"] == "/workspaces" + assert _compte_utilisateurs() == avant + 1 + # Le compte JIT ne peut pas se connecter par mot de passe local. + r = client.post("/auth/local-login", + json={"email": "draco-og-nouveau", "password": "nimporte"}) + assert r.status_code == 401 + + +def test_jit_pierre_tombale_sans_recreation(client): + _ajoute_deuxieme_utilisateur() + client.cookies.set("draco_session", _jeton(sub="og-ephemere")) + r = client.get("/auth/draco", follow_redirects=False) + assert r.headers["location"] == "/workspaces" # compte JIT créé + # La session courante est celle du compte JIT : dissociation. + r = client.delete("/auth/draco-link") + assert r.json()["ok"] is True + apres_creation = _compte_utilisateurs() + # Nouvelle arrivée du même sub : la pierre tombale interdit toute + # re-création automatique — retour à la connexion locale. + client.cookies.delete("flowdeck_session") + r = client.get("/auth/draco", follow_redirects=False) + assert r.headers["location"] == "/auth/login?provider=local" + assert _compte_utilisateurs() == apres_creation -- 2.54.0