Kanbante 0.46.0 — HUB-16 : correspondance draco par utilisateur (JIT + liaison admin) #84

Merged
bruno merged 6 commits from hub16-correspondance-draco into main 2026-11-06 09:45:10 -05:00
2 changed files with 103 additions and 52 deletions
Showing only changes of commit f6476c786d - Show all commits
+6 -3
View File
@@ -9,9 +9,12 @@ est présenté). Vérification hmac/hashlib — mêmes sémantiques que
les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0).
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
en session Kanbante native. Kanbante est mono-utilisateur
(identifiants configurés par l'environnement) : un jeton valide
désigne l'opérateur configuré — pas de table de liens. Sans secret
en session Kanbante native. Depuis la 0.46.0 (HUB-16), la
correspondance est PAR UTILISATEUR : le ``sub`` des claims désigne
le compte Kanbante lié dans ``users.draco_sub`` (résolution et
création JIT : app.accounts.resolve_draco_user) — un jeton valide
ne désigne plus « l'opérateur » par hypothèse. Ce module ne fait
que valider le jeton ; il ne connaît aucun compte. Sans secret
configuré, module inerte.
"""
+97 -49
View File
@@ -391,15 +391,24 @@ class AuthMiddleware(BaseHTTPMiddleware):
and _bearer_user(request) is not None:
return await call_next(request)
# Recherche partout (#68) : /api/search accepte aussi le jeton
# SSO draco_session (mono-utilisateur : un jeton valide désigne
# l'opérateur), comme les API de recherche des sœurs. Les
# preflights OPTIONS ne portent jamais de cookies : ils sont
# répondus par RechercheCorsMiddleware avant d'arriver ici.
# 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
# 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.
# Les preflights OPTIONS ne portent jamais de cookies : ils
# sont répondus par RechercheCorsMiddleware avant d'arriver ici.
if path == "/api/search":
try:
from app import accounts as accounts_service
from app import draco
if draco.claims_for(request) is not None:
claims = draco.claims_for(request)
if claims is not None and (
accounts_service.resolve_draco_user(claims) is not None
):
return await call_next(request)
except Exception:
pass
@@ -571,67 +580,50 @@ def draco_bridge(request: Request, next: str = "/"):
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
Point de retour de ``GET /sso/authorize`` de l'émettrice
(ObsiGate). Depuis 0.24.0 la liaison est PAR UTILISATEUR :
si le ``sub`` draco est lié à un compte, sa session est
créée ; si une session native est déjà ouverte, le ``sub``
est lié à ce compte (première visite doublement connectée) ;
sans compte lié, retour au login — la connexion native qui
suit crée le lien (voir login_submit). Sans utilisateurs en
base, comportement historique : session de l'opérateur.
(ObsiGate). Depuis la 0.46.0 (HUB-16), la correspondance est
PAR UTILISATEUR et l'hypothèse mono-utilisateur a disparu —
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é) ;
- ``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 ;
- 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.
Un ``sub`` inconnu n'ouvre plus jamais la session de
l'opérateur d'environnement ni celle d'un compte non lié.
Inerte sans ``DRACO_SESSION_SECRET``.
"""
from app import accounts as accounts_service
from app import draco
settings = get_settings()
claims = draco.claims_for(request)
if not settings.auth_enabled or claims is None:
return RedirectResponse("/login", status_code=303)
sub = claims.get("sub")
sub = str(claims.get("sub") or "")
if not sub:
return RedirectResponse("/login", status_code=303)
# Déjà connecté nativement : lier le sub à ce compte.
current = _current_user(request)
if current is not None and current.get("id") is not None:
if sub and not current.get("draco_sub"):
if not current.get("draco_sub"):
if models.get_user_by_draco(sub) is None:
models.update_user(current["id"], draco_sub=sub)
models.log_admin(current["id"], "draco.link", str(sub))
models.log_admin(current["id"], "draco.link", sub)
return RedirectResponse(_safe_next(next), status_code=303)
# Compte lié à ce sub : ouvrir sa session.
linked = models.get_user_by_draco(sub) if sub else None
if linked is not None and linked["active"]:
# Correspondance, amorçage ou création JIT : ouvrir la
# session du compte que ce sub désigne — et seulement lui.
user = accounts_service.resolve_draco_user(claims, provisionner=True)
if user is not None:
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(linked["username"]),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
if models.count_users() == 1:
# Mono-utilisateur (cas historique) : le premier sub
# valide se lie au compte unique et ouvre sa session —
# c'est la sémantique livrée en 0.23.0, conservée tant
# qu'il n'y a qu'un seul compte.
sole = models.list_users()[0]
if sole["active"]:
if sub and not sole.get("draco_sub"):
if models.get_user_by_draco(sub) is None:
models.update_user(sole["id"], draco_sub=sub)
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(sole["username"]),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
if models.count_users() == 0:
# Mode env pur (aucun compte) : session de l'opérateur.
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(settings.auth_user),
_make_session(user["username"]),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
@@ -1164,6 +1156,62 @@ async def admin_reset_user(
return RedirectResponse("/admin?msg=Mot+de+passe+r%C3%A9initialis%C3%A9", status_code=303)
@app.post("/admin/users/{user_id}/draco")
async def admin_draco_user(
request: Request,
user_id: int,
draco_sub: str = Form(""),
csrf_token: str = Form(""),
):
"""Liaison draco par l'administrateur (HUB-16, 0.46.0).
C'est le mécanisme de liaison des comptes Kanbante
préexistants : l'administrateur saisit le ``sub`` draco du
compte (colonne Draco de /admin) ; un champ vide délie.
Un ``sub`` déjà lié à un autre compte est refusé (la
correspondance est biunivoque : users.draco_sub est UNIQUE).
Contrairement au rôle et à l'activation, le geste est permis
sur son propre compte : c'est la voie manuelle qui établit la
correspondance du compte d'amorçage si le premier passage ne
l'a pas fait. Le registre matriciel côté Hub (HUB-19) lira
cette même correspondance ; ici, seul le côté Kanbante est
requis. Procédure : docs/correspondance-draco.md.
"""
from urllib.parse import quote as _q
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = models.get_user(user_id)
if target is None:
return RedirectResponse("/admin", status_code=303)
sub = draco_sub.strip()
if sub:
autre = models.get_user_by_draco(sub)
if autre is not None and autre["id"] != user_id:
return RedirectResponse(
"/admin?err="
+ _q("Identité draco déjà liée à un autre compte"),
status_code=303,
)
models.update_user(user_id, draco_sub=sub)
models.log_admin(
admin.get("id"), "draco.link.admin",
f"{target['username']} : {sub}",
)
return RedirectResponse(
"/admin?msg=" + _q("Liaison draco enregistrée"),
status_code=303,
)
models.update_user(user_id, draco_sub=None)
models.log_admin(admin.get("id"), "draco.unlink.admin", target["username"])
return RedirectResponse(
"/admin?msg=" + _q("Liaison draco retirée"), status_code=303
)
@app.get("/admin/connections")
async def admin_connections(request: Request, msg: str = "", err: str = ""):
admin = _require_admin(request)