Merge pull request 'Flowdeck 7.85.0 — HUB-15 : provisionnement JIT des comptes draco (fusion #129)' (#51) from hub15-jit into main
This commit was merged in pull request #51.
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user