# backend/routers/comptes_machine.py # HUB-13 (fusion #127) — API d'administration des comptes, machine-à-machine. # # Le Hub (interface d'administration des utilisateurs) administre les # comptes dont ObsiGate reste le magasin (décisions D1(a)/D2(a) du plan # Hub v2) via ces routes, en lecture et en écriture : lister, lire le # profil étendu, créer, modifier, désactiver/réactiver, supprimer, # 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 # d'administration existant (``/api/admin/…``), avec un segment # ``machine`` explicite qui le distingue des routes d'administration par # session (``/api/auth/admin/users``, garde ``require_admin``). Aucune # session utilisateur — même administratrice — n'ouvre ces routes : # seule la clé machine dédiée les protège. # # Authentification machine (même famille que ``GET /api/sso/revocations``) : # DRACO_ADMIN_MACHINE_KEY clé dédiée à cette API (en-tête # ``X-Draco-Machine-Key``). Distincte de # ``DRACO_SSO_MACHINE_KEY`` : le périmètre # (écriture de comptes) est plus sensible que # la seule lecture des révocations (plan §3). # Sans elle, l'API est inerte (404, comme la # liste de révocations non configurée). # X-Draco-Actor identité de service appelante, facultative # (défaut ``service-machine``) — consignée # comme acteur dans le journal d'audit. # # Chaque action est journalisée côté ObsiGate dans le journal d'audit # existant (``data/audit.log``, voir backend.audit.log_compte_admin). from __future__ import annotations import hmac import logging import os import re import time from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, validator from backend import draco_session from backend.audit import log_compte_admin from backend.auth.password import validate_password_strength from backend.auth.user_store import ( create_user, delete_user, get_all_users, get_user, update_user, ) from backend.services.net import get_client_ip logger = logging.getLogger("obsigate.comptes_machine") router = APIRouter(prefix="/api/admin/machine/comptes", tags=["admin"]) #: Variable d'environnement portant la clé machine dédiée à cette API. MACHINE_KEY_ENV = "DRACO_ADMIN_MACHINE_KEY" #: En-tête de la clé machine (même nom que pour /api/sso/revocations). MACHINE_KEY_HEADER = "X-Draco-Machine-Key" #: En-tête facultatif : identité de service appelante (journal d'audit). ACTOR_HEADER = "X-Draco-Actor" #: Acteur consigné quand l'appelant ne se nomme pas. DEFAULT_ACTOR = "service-machine" # ── Garde machine ─────────────────────────────────────────────────────── def require_machine_admin(request: Request) -> str: """Exige la clé machine dédiée ; retourne l'identité de service. Jamais de repli sur une session utilisateur : sans en-tête valide, la réponse est 401 (ou 404 si l'API n'est pas configurée, patron de ``GET /api/sso/revocations``). """ machine_key = os.environ.get(MACHINE_KEY_ENV, "") if not machine_key: raise HTTPException(404, "Non configuré") provided = request.headers.get(MACHINE_KEY_HEADER, "") if not provided or not hmac.compare_digest(provided, machine_key): raise HTTPException(401, "Clé machine invalide") actor = (request.headers.get(ACTOR_HEADER, "") or "").strip() return actor[:64] if actor else DEFAULT_ACTOR # ── Profil étendu (liste blanche — jamais de secret) ──────────────────── def _profil(user: dict) -> dict: """Profil étendu d'un compte, sûr à exposer à un service. Liste blanche stricte : ni ``password_hash``, ni secret TOTP (``mfa_secret``/``mfa_secret_pending``), ni codes de récupération, ni identifiants WebAuthn ne sortent — seul l'état MFA (oui/non) est exposé. L'avatar suit le modèle existant : la valeur stockée (data-URL ou ``None``, comme ``GET /api/auth/me``) plus le booléen de présence ``avatar_present`` pour les listes légères. """ return { "id": user.get("id"), "username": user.get("username"), "display_name": user.get("display_name"), "language": user.get("language", "fr"), "avatar": user.get("avatar"), "avatar_present": bool(user.get("avatar")), "role": user.get("role"), "active": bool(user.get("active")), "vaults": user.get("vaults", []), "mfa_enabled": bool(user.get("mfa_enabled")), "mfa_method": user.get("mfa_method"), "must_change_password": bool(user.get("must_change_password")), "created_at": user.get("created_at"), "last_login": user.get("last_login"), } def _compte_ou_404(username: str) -> dict: user = get_user(username) if user is None: raise HTTPException(404, "Compte introuvable") 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: """Révoque les sessions d'un compte ; retourne le nombre de jti draco. Les ``jti`` du cookie partagé émis pour ce compte sont révoqués via le mécanisme existant (``draco_session.revoke``, exposé aux autres applications par ``GET /api/sso/revocations``). Les jetons natifs sont invalidés par le garde ``password_changed_at`` (BUG-028), avancé par l'appelant (désactivation, réinitialisation du mot de passe) ou par le drapeau ``active`` tant que le compte est inactif. """ return draco_session.revoke_all_for_user(_identifiant_sessions(user)) def _audit(request: Request, acteur: str, action: str, cible: str, details: dict | None = None) -> None: log_compte_admin(acteur, action, cible, ip=get_client_ip(request), details=details) # ── Modèles de requête ────────────────────────────────────────────────── class CreerCompteRequest(BaseModel): username: str password: str display_name: str | None = None role: str = "user" vaults: list[str] = [] @validator("password") def password_valid(cls, v): return validate_password_strength(v) @validator("username") def username_valid(cls, v): if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v): raise ValueError("2-32 caractères alphanumériques, _ ou -") return v.lower() @validator("role") def role_valid(cls, v): if v not in ("admin", "user"): raise ValueError("Rôle invalide") return v class ModifierCompteRequest(BaseModel): display_name: str | None = None role: str | None = None language: str | None = None vaults: list[str] | None = None @validator("role") def role_valid(cls, v): if v is not None and v not in ("admin", "user"): raise ValueError("Rôle invalide") return v @validator("language") def language_valid(cls, v): if v is not None and v not in ("fr", "en"): raise ValueError("Langue invalide (fr | en)") return v class ReinitialiserMotDePasseRequest(BaseModel): password: str @validator("password") def password_valid(cls, v): return validate_password_strength(v) # ── Lecture ───────────────────────────────────────────────────────────── @router.get("") async def lister_comptes(acteur: str = Depends(require_machine_admin)): """Liste tous les comptes (profils étendus, sans aucun secret).""" comptes = [_profil(u) for u in get_all_users()] return {"comptes": comptes, "total": len(comptes)} @router.get("/{username}") async def lire_compte(username: str, acteur: str = Depends(require_machine_admin)): """Lit un compte et son profil étendu.""" return _profil(_compte_ou_404(username)) # ── Écriture ──────────────────────────────────────────────────────────── @router.post("", status_code=201) async def creer_compte(req: CreerCompteRequest, request: Request, acteur: str = Depends(require_machine_admin)): """Crée un compte. Le mot de passe initial est temporaire : ``must_change_password`` est posé (changement forcé signalé à la première connexion, comme après une réinitialisation).""" if get_user(req.username) is not None: raise HTTPException(409, "Identifiant déjà utilisé") try: user = create_user( req.username, req.password, req.role, req.vaults, req.display_name, must_change_password=True, ) except ValueError as e: raise HTTPException(409, str(e)) from e # #194 : dossier personnel — même provisionnement que la création # par session administratrice (best effort). from backend.user_home import ensure_user_home await ensure_user_home(req.username) fresh = get_user(req.username) if fresh: user = fresh _audit(request, acteur, "compte_cree", req.username, {"role": req.role}) return _profil(user) @router.patch("/{username}") async def modifier_compte(username: str, req: ModifierCompteRequest, request: Request, acteur: str = Depends(require_machine_admin)): """Modifie un compte (nom affiché, rôle global, langue, vaults).""" _compte_ou_404(username) updates = req.dict(exclude_none=True) if not updates: raise HTTPException(400, "Aucun champ à modifier") updated = update_user(username, updates) _audit(request, acteur, "compte_modifie", username, {"champs": sorted(updates.keys())}) return _profil(updated) @router.post("/{username}/desactiver") async def desactiver_compte(username: str, request: Request, acteur: str = Depends(require_machine_admin)): """Désactive un compte : plus aucun nouveau jeton n'est émis (connexion refusée) et les ``jti`` draco actifs sont révoqués.""" user = _compte_ou_404(username) updated = update_user(username, { "active": False, # BUG-028 : avancer le repère invalide aussi les jetons natifs # déjà émis — ils ne ressuscitent pas à la réactivation. "password_changed_at": time.time(), }) nb_jti = _revoquer_sessions(user) _audit(request, acteur, "compte_desactive", username, {"jti_revoques": nb_jti}) return _profil(updated) @router.post("/{username}/reactiver") async def reactiver_compte(username: str, request: Request, acteur: str = Depends(require_machine_admin)): """Réactive un compte désactivé (les anciennes sessions restent révoquées : une nouvelle connexion est nécessaire).""" _compte_ou_404(username) updated = update_user(username, {"active": True}) _audit(request, acteur, "compte_reactive", username) return _profil(updated) @router.delete("/{username}") async def supprimer_compte(username: str, request: Request, acteur: str = Depends(require_machine_admin)): """Supprime un compte et révoque ses ``jti`` draco actifs.""" user = _compte_ou_404(username) nb_jti = _revoquer_sessions(user) try: delete_user(username) except ValueError as e: raise HTTPException(404, str(e)) from e # #194 : fermer le vault du dossier personnel (dossier conservé), # comme la suppression par session administratrice. from backend.user_home import release_user_home await release_user_home(username) _audit(request, acteur, "compte_supprime", username, {"jti_revoques": nb_jti}) return {"message": f"Compte '{username}' supprimé"} @router.post("/{username}/reinitialiser-mot-de-passe") async def reinitialiser_mot_de_passe( username: str, req: ReinitialiserMotDePasseRequest, request: Request, acteur: str = Depends(require_machine_admin)): """Pose un nouveau mot de passe temporaire : changement forcé à la première connexion (``must_change_password``, signalé par la connexion et ``GET /api/auth/me``, effacé au changement effectif). Les sessions existantes sont révoquées (repère BUG-028 + ``jti`` draco). Le mot de passe n'est jamais journalisé.""" user = _compte_ou_404(username) updated = update_user(username, { "password": req.password, "must_change_password": True, }) nb_jti = _revoquer_sessions(user) _audit(request, acteur, "mot_de_passe_reinitialise", username, {"jti_revoques": nb_jti}) return _profil(updated) @router.post("/{username}/reinitialiser-mfa") async def reinitialiser_mfa(username: str, request: Request, acteur: str = Depends(require_machine_admin)): """Efface l'enrôlement TOTP d'un compte (utilisateur verrouillé hors de son second facteur) : secret, secret en attente et codes de récupération sont supprimés. Les clés WebAuthn éventuelles sont conservées et gardent alors le MFA actif. Le secret n'est jamais exposé — ni avant, ni après.""" user = _compte_ou_404(username) webauthn_restantes = len(user.get("webauthn_credentials") or []) updates: dict = { "mfa_secret": None, "mfa_secret_pending": None, "mfa_recovery_codes": [], } if webauthn_restantes: updates["mfa_method"] = "webauthn" else: updates["mfa_enabled"] = False updates["mfa_method"] = None updated = update_user(username, updates) _audit(request, acteur, "mfa_reinitialise", username, {"webauthn_conserves": webauthn_restantes}) 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}