Flowdeck 7.85.0 — HUB-15 : provisionnement JIT des comptes draco (fusion #129)
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 13s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 12s

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).
This commit is contained in:
Spark
2026-11-06 14:03:23 +00:00
parent 54bd43e9f2
commit 296d9cfa25
5 changed files with 167 additions and 11 deletions
+8
View File
@@ -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-<sub>` (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.
+1 -1
View File
@@ -1 +1 @@
7.84.15
7.85.0
+1 -1
View File
@@ -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()
+85 -1
View File
@@ -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
+72 -8
View File
@@ -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', '[email protected]', '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', '[email protected]', '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