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
+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