feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227)

This commit is contained in:
Spark
2026-11-06 11:07:24 -05:00
parent 222cd0b562
commit 44a8154cae
+75 -2
View File
@@ -7,6 +7,12 @@
# profil étendu, créer, modifier, désactiver/réactiver, supprimer, # profil étendu, créer, modifier, désactiver/réactiver, supprimer,
# réinitialiser le mot de passe et le MFA. # réinitialiser le mot de passe et le MFA.
# #
# HUB-22 (#227, vague C du plan Hub v2.1) ajoute, sous la même garde et
# avec la même journalisation, les routes « sessions » : lister les
# sessions draco actives d'un compte, en révoquer une (par son `jti`,
# jamais celle d'un autre compte) ou toutes (déconnexion globale, sans
# désactiver le compte), via le registre des émissions de HUB-13.
#
# Emplacement : ``/api/admin/machine/comptes`` — dans l'espace # Emplacement : ``/api/admin/machine/comptes`` — dans l'espace
# d'administration existant (``/api/admin/…``), avec un segment # d'administration existant (``/api/admin/…``), avec un segment
# ``machine`` explicite qui le distingue des routes d'administration par # ``machine`` explicite qui le distingue des routes d'administration par
@@ -36,6 +42,7 @@ import logging
import os import os
import re import re
import time import time
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
@@ -122,6 +129,16 @@ def _compte_ou_404(username: str) -> dict:
return user return user
def _identifiant_sessions(user: dict) -> str:
"""Clé du registre des émissions draco pour un compte.
C'est le ``sub`` des jetons draco : l'identifiant du compte
(``id``), à défaut son nom d'utilisateur — exactement la clé
qu'utilise ``_revoquer_sessions`` depuis HUB-13.
"""
return str(user.get("id") or user.get("username") or "")
def _revoquer_sessions(user: dict) -> int: def _revoquer_sessions(user: dict) -> int:
"""Révoque les sessions d'un compte ; retourne le nombre de jti draco. """Révoque les sessions d'un compte ; retourne le nombre de jti draco.
@@ -132,8 +149,7 @@ def _revoquer_sessions(user: dict) -> int:
avancé par l'appelant (désactivation, réinitialisation du mot de avancé par l'appelant (désactivation, réinitialisation du mot de
passe) ou par le drapeau ``active`` tant que le compte est inactif. passe) ou par le drapeau ``active`` tant que le compte est inactif.
""" """
sub = str(user.get("id") or user.get("username") or "") return draco_session.revoke_all_for_user(_identifiant_sessions(user))
return draco_session.revoke_all_for_user(sub)
def _audit(request: Request, acteur: str, action: str, cible: str, def _audit(request: Request, acteur: str, action: str, cible: str,
@@ -349,3 +365,60 @@ async def reinitialiser_mfa(username: str, request: Request,
_audit(request, acteur, "mfa_reinitialise", username, _audit(request, acteur, "mfa_reinitialise", username,
{"webauthn_conserves": webauthn_restantes}) {"webauthn_conserves": webauthn_restantes})
return _profil(updated) return _profil(updated)
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
@router.get("/{username}/sessions")
async def lister_sessions(username: str,
acteur: str = Depends(require_machine_admin)):
"""Liste les sessions draco actives d'un compte.
Une session par ``jti`` émis et non expiré (registre des émissions
de HUB-13), triées par expiration croissante ; ``revoquee`` signale
un ``jti`` déjà présent dans la liste des révocations et
``expire_le`` est l'échéance ``exp`` en ISO 8601 UTC.
"""
user = _compte_ou_404(username)
sessions = [{
"jti": s["jti"],
"exp": s["exp"],
"expire_le": datetime.fromtimestamp(s["exp"], tz=UTC).isoformat(),
"revoquee": s["revoquee"],
} for s in draco_session.issued_sessions_for_user(
_identifiant_sessions(user))]
return {"username": username, "sessions": sessions}
@router.post("/{username}/sessions/{jti}/revoquer")
async def revoquer_session(username: str, jti: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque une seule session draco d'un compte, par son ``jti``.
404 si le ``jti`` ne figure pas dans l'ensemble actif de ce
compte : une session d'un autre utilisateur n'est jamais révoquée
par cette route (aucune révocation cross-utilisateur).
"""
user = _compte_ou_404(username)
if not draco_session.revoke_one_for_user(
_identifiant_sessions(user), jti):
raise HTTPException(404, "Session introuvable")
_audit(request, acteur, "session_revoquee", username, {"jti": jti})
return {"jti": jti, "revoquee": True}
@router.post("/{username}/sessions/revoquer-toutes")
async def revoquer_toutes_sessions(
username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque toutes les sessions draco actives d'un compte.
C'est la « déconnexion globale » : contrairement à la
désactivation, le compte reste actif — seule l'émission d'une
nouvelle session (nouvelle connexion) rouvre l'accès.
"""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "sessions_revoquees", username,
{"jti_revoques": nb_jti})
return {"revoquees": nb_jti}