# 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. # # 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 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 _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. """ sub = str(user.get("id") or user.get("username") or "") return draco_session.revoke_all_for_user(sub) 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)