From 65d01f91b8b5ef4b3d8ff0ef41071913c68e90a4 Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 6 Nov 2026 00:58:16 +0000 Subject: [PATCH] =?UTF-8?q?ObsiGate=202.84.0=20:=20API=20d'administration?= =?UTF-8?q?=20des=20comptes=20machine-=C3=A0-machine=20(HUB-13,=20fusion?= =?UTF-8?q?=20#127,=20#226)=20=E2=80=94=20routes=20/api/admin/machine/comp?= =?UTF-8?q?tes=20prot=C3=A9g=C3=A9es=20par=20DRACO=5FADMIN=5FMACHINE=5FKEY?= =?UTF-8?q?,=20r=C3=A9vocation=20des=20jti=20draco=20=C3=A0=20la=20d=C3=A9?= =?UTF-8?q?sactivation,=20must=5Fchange=5Fpassword,=20journalisation=20d'a?= =?UTF-8?q?udit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 +- backend/audit.py | 29 ++ backend/auth/router.py | 10 +- backend/auth/user_store.py | 11 +- backend/draco_session.py | 75 +++- backend/main.py | 2 + backend/routers/comptes_machine.py | 351 +++++++++++++++++++ docs/ROADMAP.md | 20 +- docs/features/hub-api-admin-comptes-226.md | 105 ++++++ tests/test_comptes_machine.py | 377 +++++++++++++++++++++ 10 files changed, 983 insertions(+), 5 deletions(-) create mode 100644 backend/routers/comptes_machine.py create mode 100644 docs/features/hub-api-admin-comptes-226.md create mode 100644 tests/test_comptes_machine.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ce50a..63719f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.83.5**. +> [Unreleased](#unreleased). La dernière version livrée est **2.84.0**. --- @@ -14,6 +14,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.84.0] — 2026-11-06 + +- **HUB-13 — API d'administration des comptes, machine-à-machine (fusion #127, #226)** : nouvelles routes `/api/admin/machine/comptes` (liste, profil étendu, création, modification, désactivation/réactivation, suppression, réinitialisation du mot de passe et du MFA), protégées par la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, distincte de `DRACO_SSO_MACHINE_KEY` ; aucune session utilisateur ne les ouvre ; API inerte si la clé n'est pas configurée). Le profil étendu expose nom d'utilisateur, nom affiché, langue, avatar (valeur + présence), rôle global, état actif, MFA activé oui/non — jamais le secret TOTP ni le haché du mot de passe. La désactivation arrête l'émission de nouveaux jetons et révoque les `jti` draco actifs : `draco_session` tient désormais un registre des émissions (`data/draco_issued.json`) et `revoke_all_for_user()` révoque via la liste existante ; le repère `password_changed_at` (BUG-028) est aussi avancé. Nouveau champ utilisateur `must_change_password` : posé à la création machine et à la réinitialisation du mot de passe, signalé par la connexion et `GET /api/auth/me`, effacé au changement effectif. Chaque action est journalisée dans `data/audit.log` (acteur = identité de service `X-Draco-Actor`, action, compte cible, horodatage). Fiche : `docs/features/hub-api-admin-comptes-226.md`. Tests : `tests/test_comptes_machine.py`. + +--- + ## [2.83.5] — 2026-11-05 - **Barre commune v0.4.1 — correctif des icônes invisibles du menu compte (fusion #125)** : seul `frontend/draco-barre.js` change, re-vendangé **verbatim** depuis dracodev-commons (branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11) ; le CSS est inchangé. En v0.4.0, `menuIcone()` créait le `` des icônes et ses primitives via `document.createElement()` — espace de noms **HTML**, jamais dessinés par le navigateur : les libellés du menu compte étaient décalés d'un cran d'icône, sans aucun glyphe. La v0.4.1 crée tout via `document.createElementNS()` (espace de noms SVG). Glyphes, CSS et comportements inchangés. Garde ajoutée à `tests/test_draco_barre.py`. diff --git a/backend/audit.py b/backend/audit.py index 42d615d..97bb6d1 100644 --- a/backend/audit.py +++ b/backend/audit.py @@ -154,6 +154,35 @@ def log_coffre_event( _write_entry(entry) +def log_compte_admin( + acteur: str, + action: str, + cible: str, + ip: str | None = None, + details: dict | None = None, +): + """Journal d'une action d'administration de compte (HUB-13, fusion #127). + + ``acteur`` est l'identité de service appelante (API machine des + comptes, en-tête ``X-Draco-Actor``), ``cible`` le compte visé. + Règle absolue : ``details`` ne contient jamais de mot de passe, de + secret TOTP ni de jeton — seulement des métadonnées (champs + modifiés, nombre de jti révoqués…). Le champ ``username`` reprend + l'acteur pour rester filtrable par l'écran d'audit existant. + """ + entry: dict = { + "timestamp": datetime.now(UTC).isoformat(), + "action": action, + "username": acteur, + "acteur": acteur, + "cible": cible, + "ip": ip or "unknown", + } + if details: + entry["details"] = details + _write_entry(entry) + + def get_recent_entries(limit: int = 100, action: str | None = None) -> list: """Read the most recent audit log entries. diff --git a/backend/auth/router.py b/backend/auth/router.py index ff78aba..b2731f2 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -298,6 +298,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon "role": user["role"], "vaults": user["vaults"], "avatar": user.get("avatar"), + # HUB-13 : mot de passe temporaire — changement forcé signalé. + "must_change_password": bool(user.get("must_change_password")), }, } @@ -433,6 +435,8 @@ async def get_me(current_user=Depends(require_auth)): "language": current_user.get("language", "fr"), "last_login": current_user.get("last_login"), "avatar": current_user.get("avatar"), + # HUB-13 : mot de passe temporaire — changement forcé signalé. + "must_change_password": bool(current_user.get("must_change_password")), } @@ -487,7 +491,11 @@ async def change_password( assert user is not None, f"User {current_user['username']} not found" if not verify_password(req.current_password, user["password_hash"]): raise HTTPException(400, "Mot de passe actuel incorrect") - update_user(current_user["username"], {"password": req.new_password}) + # HUB-13 : le changement effectif efface le marqueur de changement forcé. + update_user(current_user["username"], { + "password": req.new_password, + "must_change_password": False, + }) updated = get_user(current_user["username"]) result: dict = {"message": "Mot de passe mis à jour"} if updated is not None: diff --git a/backend/auth/user_store.py b/backend/auth/user_store.py index 0c3309a..939244c 100644 --- a/backend/auth/user_store.py +++ b/backend/auth/user_store.py @@ -80,8 +80,16 @@ def create_user( role: str = "user", vaults: list[str] | None = None, display_name: str | None = None, + must_change_password: bool = False, ) -> dict: - """Create a new user. Raises ValueError if username already taken.""" + """Create a new user. Raises ValueError if username already taken. + + ``must_change_password`` (HUB-13) marque un mot de passe temporaire + (création/réinitialisation par l'administration machine) : le + changement est forcé à la première connexion. Les comptes créés + avant ce champ n'en portent pas — ils valent ``False`` partout où + le champ est lu via ``.get(..., False)``. + """ with _users_lock: data = _read() if username in data["users"]: @@ -95,6 +103,7 @@ def create_user( "role": role, "vaults": vaults or [], "active": True, + "must_change_password": bool(must_change_password), # HUB-13 "language": "fr", # default UI language "avatar": None, # profile picture data-URL (#113) "created_at": datetime.now(UTC).isoformat(), diff --git a/backend/draco_session.py b/backend/draco_session.py index 33d3148..6b0e335 100644 --- a/backend/draco_session.py +++ b/backend/draco_session.py @@ -19,6 +19,12 @@ # vide = cookie lié à l'hôte, utile en dev) # DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la # liste de révocation (GET /api/sso/revocations) +# +# Registre des émissions (HUB-13, fusion #127) : chaque jti émis est +# consigné par `sub` dans data/draco_issued.json (jti -> exp, purgé à +# l'expiration) pour que l'administration des comptes puisse révoquer +# toutes les sessions draco d'un utilisateur (désactivation, +# suppression, réinitialisation du mot de passe) via revoke(). from __future__ import annotations @@ -40,10 +46,12 @@ ALGORITHM = "HS256" TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2) RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h REVOKED_FILE = Path("data/draco_revoked.json") +ISSUED_FILE = Path("data/draco_issued.json") ALLOWED_RETURN_SUFFIX = "dracodev.net" _lock = threading.Lock() _revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement) +_issued: dict[str, dict[str, int]] | None = None # sub -> {jti -> exp} def _secret() -> str: @@ -108,6 +116,69 @@ def revoked_list() -> list[dict]: for jti, exp in _load_revoked().items() if exp > now] +# ── Registre des émissions (par utilisateur) ──────────────────────────── + +def _load_issued() -> dict[str, dict[str, int]]: + global _issued + if _issued is None: + try: + data = json.loads(ISSUED_FILE.read_text(encoding="utf-8")) + _issued = { + str(sub): {str(j): int(e) for j, e in tokens.items()} + for sub, tokens in data.items() + if isinstance(tokens, dict) + } if isinstance(data, dict) else {} + except (OSError, ValueError): + _issued = {} + return _issued + + +def _save_issued() -> None: + ISSUED_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = ISSUED_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(_issued or {}), encoding="utf-8") + tmp.replace(ISSUED_FILE) + + +def _register_issued(sub: str, jti: str, exp: int) -> None: + """Consigne un jti émis pour ``sub`` (purge des expirés au passage).""" + if not sub: + return + try: + with _lock: + store = _load_issued() + now = int(time.time()) + for key in list(store.keys()): + store[key] = {j: e for j, e in store[key].items() if e > now} + if not store[key]: + del store[key] + store.setdefault(sub, {})[jti] = int(exp) + _save_issued() + except Exception: # le registre ne casse jamais une émission + logger.warning("Registre des émissions draco non écrit", exc_info=True) + + +def revoke_all_for_user(sub: str) -> int: + """Révoque tous les jti draco actifs émis pour ``sub``. + + Retourne le nombre de jti révoqués. Utilisé par l'administration + des comptes (HUB-13) : désactivation, suppression, réinitialisation + du mot de passe. + """ + if not sub: + return 0 + with _lock: + store = _load_issued() + tokens = store.pop(str(sub), {}) + now = int(time.time()) + actifs = {j: e for j, e in tokens.items() if e > now} + if tokens: + _save_issued() + for jti, exp in actifs.items(): + revoke(jti, exp) + return len(actifs) + + # ── Émission / vérification ───────────────────────────────────────────── def issue_token(user: dict) -> tuple[str, str, int]: @@ -122,7 +193,9 @@ def issue_token(user: dict) -> tuple[str, str, int]: "exp": exp, "jti": jti, } - return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp + token = jwt.encode(claims, _secret(), algorithm=ALGORITHM) + _register_issued(str(claims["sub"]), jti, exp) + return token, jti, exp def decode_claims(token: str) -> dict | None: diff --git a/backend/main.py b/backend/main.py index 979879f..854b877 100644 --- a/backend/main.py +++ b/backend/main.py @@ -550,6 +550,7 @@ from backend.auth.router import router as auth_router from backend.bookslm_routes import router as bookslm_router from backend.routers.backups import router as backups_router from backend.routers.coffres import router as coffres_router +from backend.routers.comptes_machine import router as comptes_machine_router from backend.routers.config import _load_config from backend.routers.config import router as config_router from backend.routers.conflicts import router as conflicts_router @@ -585,6 +586,7 @@ app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts app.include_router(config_router) # ROADMAP #85 T7 — Config app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py +app.include_router(comptes_machine_router) # HUB-13 (fusion #127) — Admin des comptes, machine-à-machine app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier app.include_router(files_read_router) # ROADMAP #85 T6a — Files read app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export diff --git a/backend/routers/comptes_machine.py b/backend/routers/comptes_machine.py new file mode 100644 index 0000000..2fabb82 --- /dev/null +++ b/backend/routers/comptes_machine.py @@ -0,0 +1,351 @@ +# 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) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a968090..40e082a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.83.5 | **Dernière mise à jour :** 2026-11-05 +> **Version :** 2.84.0 | **Dernière mise à jour :** 2026-11-06 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -30,6 +30,24 @@ ## 🔵 En cours +### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine) + +- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis) +- **Fiche :** [features/hub-api-admin-comptes-226.md](./features/hub-api-admin-comptes-226.md) +- **Description :** carte fusion #127 (plan Hub v2 §2.2, D1(a)/D2(a)) : le Hub + administre les comptes dont ObsiGate reste le magasin, via + `/api/admin/machine/comptes` (liste, profil étendu, création, + modification, désactivation/réactivation avec révocation des `jti` + draco, suppression, réinitialisation du mot de passe avec changement + forcé `must_change_password`, réinitialisation du MFA), protégée par + la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` — jamais par une + session. Chaque action est journalisée dans `data/audit.log`. +- **Sous-tâches :** + - [x] Routes machine lecture/écriture + garde par clé dédiée (`backend/routers/comptes_machine.py`) + - [x] Registre des émissions draco + `revoke_all_for_user` (`backend/draco_session.py`) + - [x] Champ `must_change_password` (modèle, connexion, `/me`, changement de mot de passe) + - [x] Journalisation `log_compte_admin` (`backend/audit.py`) + tests `tests/test_comptes_machine.py` + ### 225. Fusion — panneau Spark : version du service d'agents visible en mode sidebar - **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-26 | **Statut :** ✅ livré le 2026-10-26 (version du hook — voir CHANGELOG ; redéploiement Docker requis) diff --git a/docs/features/hub-api-admin-comptes-226.md b/docs/features/hub-api-admin-comptes-226.md new file mode 100644 index 0000000..b43088f --- /dev/null +++ b/docs/features/hub-api-admin-comptes-226.md @@ -0,0 +1,105 @@ +# #226 — HUB-13 : API d'administration des comptes (machine-à-machine) + +**Carte :** fusion #127 (HUB-13, plan Hub v2 §2.2 et §4 vague A ; décisions +D1(a) — magasin des comptes chez ObsiGate — et D2(a) — ObsiGate émettrice). +**Version :** 2.84.0. + +## Objet + +Le Hub administre les comptes de la suite depuis son interface +d'administration ; ObsiGate reste le magasin des comptes. Cette API est +le bras armé de cette interface : administration complète des comptes, +en lecture et en écriture, réservée aux appels machine-à-machine. + +## Routes — `/api/admin/machine/comptes` + +Emplacement choisi : l'espace d'administration existant (`/api/admin/…`), +segment `machine` explicite. Il distingue ces routes de l'administration +par session (`/api/auth/admin/users`, garde `require_admin`) : aucune +session utilisateur, même administratrice, ne les ouvre. + +| Méthode | Chemin | Effet | +|---|---|---| +| GET | `/api/admin/machine/comptes` | Liste des comptes (profils étendus) | +| GET | `/api/admin/machine/comptes/{username}` | Profil étendu d'un compte | +| POST | `/api/admin/machine/comptes` | Création (201 ; 409 si identifiant pris) | +| PATCH | `/api/admin/machine/comptes/{username}` | Modification (nom affiché, rôle global, langue, vaults) | +| POST | `/api/admin/machine/comptes/{username}/desactiver` | Désactivation + révocation des sessions | +| POST | `/api/admin/machine/comptes/{username}/reactiver` | Réactivation | +| DELETE | `/api/admin/machine/comptes/{username}` | Suppression + révocation des sessions | +| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mot-de-passe` | Nouveau mot de passe temporaire, changement forcé | +| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mfa` | Effacement de l'enrôlement TOTP | + +Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non +configurée), 409 conflit d'identifiant, 422 corps invalide. + +## Protection + +- Clé machine **dédiée** : `DRACO_ADMIN_MACHINE_KEY`, en-tête + `X-Draco-Machine-Key` (même famille et même en-tête que + `GET /api/sso/revocations`). Distincte de `DRACO_SSO_MACHINE_KEY` : + l'écriture de comptes est plus sensible que la lecture des + révocations (plan §3). Comparaison à temps constant. +- Sans la variable, l'API est inerte (404 « Non configuré »). +- Acteur consigné : en-tête facultatif `X-Draco-Actor` (défaut + `service-machine`). + +## Profil étendu + +Liste blanche stricte : `id`, `username`, `display_name`, `language`, +`avatar` (valeur du modèle — data-URL ou `null`, comme +`GET /api/auth/me`) et `avatar_present`, `role`, `active`, `vaults`, +`mfa_enabled` (oui/non), `mfa_method`, `must_change_password`, +`created_at`, `last_login`. **Jamais** le haché du mot de passe, le +secret TOTP, les codes de récupération ni les identifiants WebAuthn. + +## Désactivation et sessions + +- `active = false` → la connexion est refusée (401 uniforme, BUG-039) + et plus aucun jeton n'est émis. +- Les `jti` draco émis pour le compte sont révoqués via le mécanisme + existant (`draco_session.revoke`, liste publiée par + `GET /api/sso/revocations`). Pour les connaître, `draco_session` + tient désormais un registre des émissions (`data/draco_issued.json`, + `jti → exp` par `sub`, purgé à l'expiration) et + `revoke_all_for_user(sub)`. +- Le repère `password_changed_at` (BUG-028) est avancé : les jetons + natifs déjà émis sont invalidés et ne ressuscitent pas à la + réactivation. Les anciennes sessions draco restent révoquées après + réactivation : une nouvelle connexion est nécessaire. + +## Changement forcé du mot de passe + +Nouveau champ du modèle utilisateur : `must_change_password` (booléen, +défaut `false` — ajouté par `create_user`, aucun fichier à migrer : +les comptes existants sans le champ valent `false`). Posé à la +création machine et à la réinitialisation du mot de passe ; signalé +dans la réponse de connexion et `GET /api/auth/me` ; effacé par +`POST /api/auth/change-password`. + +## Réinitialisation du MFA + +Efface `mfa_secret`, `mfa_secret_pending` et `mfa_recovery_codes`. +Les clés WebAuthn éventuelles sont conservées et gardent le MFA +actif (méthode `webauthn`) ; sans elles, `mfa_enabled` passe à `false`. + +## Journalisation + +Chaque action est consignée dans le journal d'audit existant +(`data/audit.log`, JSON lignes) via `backend.audit.log_compte_admin` : +horodatage, `acteur` (identité de service), `action` (`compte_cree`, +`compte_modifie`, `compte_desactive`, `compte_reactive`, +`compte_supprime`, `mot_de_passe_reinitialise`, `mfa_reinitialise`), +`cible` (compte visé), IP et détails non sensibles (champs modifiés, +nombre de `jti` révoqués — jamais de mot de passe ni de secret). + +## Tests + +`tests/test_comptes_machine.py` : refus sans clé / mauvaise clé / clé +SSO / session admin, API inerte non configurée, liste et profil étendu +sans secret, création puis lecture et connexion, doublon 409, +modification, désactivation (connexion refusée, session native +refusée, `jti` draco révoqué et publié) puis réactivation, +réinitialisation du mot de passe (ancien refusé, nouveau accepté, +changement forcé signalé puis effacé au changement), réinitialisation +MFA (connexion directe après), suppression, journalisation. diff --git a/tests/test_comptes_machine.py b/tests/test_comptes_machine.py new file mode 100644 index 0000000..f49332d --- /dev/null +++ b/tests/test_comptes_machine.py @@ -0,0 +1,377 @@ +# tests/test_comptes_machine.py — HUB-13 (fusion #127) +# API d'administration des comptes machine-à-machine +# (/api/admin/machine/comptes), protégée par la clé dédiée +# DRACO_ADMIN_MACHINE_KEY : lecture/écriture, désactivation avec +# révocation des sessions, réinitialisations du mot de passe (avec +# changement forcé) et du MFA, suppression, journalisation d'audit. + +import json +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + +MACHINE_KEY = "cle-admin-machine-test" +MACHINE_HEADERS = {"X-Draco-Machine-Key": MACHINE_KEY, + "X-Draco-Actor": "hub-test"} +BASE = "/api/admin/machine/comptes" + + +@pytest.fixture +def machine_client(): + """TestClient avec auth active, données isolées, clés machine posées.""" + tmp = Path(tempfile.mkdtemp()) + data_dir = tmp / "data" + data_dir.mkdir() + + from backend.auth.mfa import generate_secret + from backend.auth.password import hash_password + + users = { + "version": 1, + "users": { + "admin": { + "id": "admin-1", + "username": "admin", + "display_name": "admin", + "password_hash": hash_password("chab30"), + "role": "admin", + "vaults": ["*"], + "active": True, + "created_at": "2026-01-01T00:00:00", + }, + "mfauser": { + "id": "user-mfa", + "username": "mfauser", + "display_name": "Utilisateur MFA", + "password_hash": hash_password("motdepasse-mfa"), + "role": "user", + "vaults": ["TestVault"], + "active": True, + "created_at": "2026-01-01T00:00:00", + "mfa_enabled": True, + "mfa_secret": generate_secret(), + "mfa_method": "totp", + "mfa_recovery_codes": ["hash-fictif"], + }, + }, + } + (data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8") + + src_secret = Path("data/secret.key") + if src_secret.exists(): + shutil.copy2(str(src_secret), str(data_dir / "secret.key")) + + orig_cwd = os.getcwd() + test_vault_path = os.path.abspath("test-vault") + os.chdir(str(tmp)) + + os.environ["VAULT_1_NAME"] = "TestVault" + os.environ["VAULT_1_PATH"] = test_vault_path + os.environ["OBSIGATE_AUTH_ENABLED"] = "true" + os.environ["OBSIGATE_WATCHER_ENABLED"] = "false" + os.environ["DRACO_ADMIN_MACHINE_KEY"] = MACHINE_KEY + os.environ["DRACO_SSO_MACHINE_KEY"] = "cle-sso-test" + os.environ["DRACO_SESSION_SECRET"] = "secret-draco-test" + + from backend import draco_session + draco_session._revoked = None + draco_session._issued = None + + import backend.main + backend.main._load_config = lambda: {"watcher_enabled": False} + + from backend.main import app + from backend.indexer import build_index, index + import asyncio + + for key in list(index.keys()): + del index[key] + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(build_index()) + + from backend.search import init_inverted_index + init_inverted_index() + + from fastapi.testclient import TestClient + client = TestClient(app) + yield client + + if hasattr(client, "close"): + client.close() + loop.run_until_complete(asyncio.sleep(0)) + + draco_session._revoked = None + draco_session._issued = None + os.chdir(orig_cwd) + shutil.rmtree(str(tmp), ignore_errors=True) + for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED", + "OBSIGATE_WATCHER_ENABLED", "DRACO_ADMIN_MACHINE_KEY", + "DRACO_SSO_MACHINE_KEY", "DRACO_SESSION_SECRET"]: + os.environ.pop(k, None) + + +def _login(client, username, password): + return client.post("/api/auth/login", + json={"username": username, "password": password}) + + +def _creer(client, username="nouveau", password="motdepasse-initial", **kw): + corps = {"username": username, "password": password, + "display_name": kw.pop("display_name", "Nouveau Compte"), + "role": kw.pop("role", "user")} + corps.update(kw) + return client.post(BASE, headers=MACHINE_HEADERS, json=corps) + + +# ── Protection par clé machine ────────────────────────────────────────── + +class TestProtection: + def test_sans_cle_refuse(self, machine_client): + assert machine_client.get(BASE).status_code == 401 + assert machine_client.post( + BASE, json={"username": "x1", "password": "motdepasse"} + ).status_code == 401 + + def test_mauvaise_cle_refusee(self, machine_client): + resp = machine_client.get( + BASE, headers={"X-Draco-Machine-Key": "mauvaise-cle"}) + assert resp.status_code == 401 + + def test_cle_sso_ne_suffit_pas(self, machine_client): + """La clé des révocations (lecture) n'ouvre pas l'administration.""" + resp = machine_client.get( + BASE, headers={"X-Draco-Machine-Key": "cle-sso-test"}) + assert resp.status_code == 401 + + def test_session_admin_ne_suffit_pas(self, machine_client): + """Même une session administratrice ordinaire est refusée.""" + token = _login(machine_client, "admin", "chab30").json()["access_token"] + resp = machine_client.get( + BASE, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 401 + + def test_non_configuree_inerte(self, machine_client, monkeypatch): + monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY") + resp = machine_client.get(BASE, headers=MACHINE_HEADERS) + assert resp.status_code == 404 + + +# ── Lecture ───────────────────────────────────────────────────────────── + +class TestLecture: + def test_liste(self, machine_client): + resp = machine_client.get(BASE, headers=MACHINE_HEADERS) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 2 + noms = [c["username"] for c in data["comptes"]] + assert "admin" in noms and "mfauser" in noms + + def test_profil_etendu_sans_secret(self, machine_client): + resp = machine_client.get(f"{BASE}/mfauser", headers=MACHINE_HEADERS) + assert resp.status_code == 200 + profil = resp.json() + assert profil["username"] == "mfauser" + assert profil["display_name"] == "Utilisateur MFA" + assert profil["language"] == "fr" + assert profil["avatar_present"] is False + assert profil["role"] == "user" + assert profil["active"] is True + assert profil["mfa_enabled"] is True + # Jamais de secret : ni le haché du mot de passe, ni le TOTP. + brut = resp.text + assert "password_hash" not in brut + assert "mfa_secret" not in brut + assert "mfa_recovery" not in brut + + def test_compte_inconnu_404(self, machine_client): + resp = machine_client.get(f"{BASE}/personne", headers=MACHINE_HEADERS) + assert resp.status_code == 404 + + +# ── Création / modification ───────────────────────────────────────────── + +class TestCreationModification: + def test_creation_puis_lecture(self, machine_client): + resp = _creer(machine_client) + assert resp.status_code == 201, resp.text + profil = resp.json() + assert profil["username"] == "nouveau" + assert profil["must_change_password"] is True + + lu = machine_client.get(f"{BASE}/nouveau", headers=MACHINE_HEADERS) + assert lu.status_code == 200 + assert lu.json()["display_name"] == "Nouveau Compte" + + # Le compte créé se connecte, avec changement forcé signalé. + login = _login(machine_client, "nouveau", "motdepasse-initial") + assert login.status_code == 200 + assert login.json()["user"]["must_change_password"] is True + + def test_creation_doublon_409(self, machine_client): + assert _creer(machine_client).status_code == 201 + assert _creer(machine_client).status_code == 409 + assert _creer(machine_client, username="admin").status_code == 409 + + def test_modification(self, machine_client): + assert _creer(machine_client).status_code == 201 + resp = machine_client.patch( + f"{BASE}/nouveau", headers=MACHINE_HEADERS, + json={"display_name": "Nom Modifié", "role": "admin"}) + assert resp.status_code == 200 + assert resp.json()["display_name"] == "Nom Modifié" + assert resp.json()["role"] == "admin" + + def test_modification_inconnue_404(self, machine_client): + resp = machine_client.patch( + f"{BASE}/personne", headers=MACHINE_HEADERS, + json={"display_name": "X"}) + assert resp.status_code == 404 + + +# ── Désactivation / réactivation ──────────────────────────────────────── + +class TestDesactivation: + def test_desactivation_revoque_puis_reactivation(self, machine_client): + from backend import draco_session + + assert _creer(machine_client).status_code == 201 + login = _login(machine_client, "nouveau", "motdepasse-initial") + assert login.status_code == 200 + token_natif = login.json()["access_token"] + + profil = machine_client.get( + f"{BASE}/nouveau", headers=MACHINE_HEADERS).json() + token_draco, jti, _exp = draco_session.issue_token( + {"id": profil["id"], "username": "nouveau", + "display_name": "Nouveau Compte"}) + assert draco_session.verify_token(token_draco) is not None + + resp = machine_client.post(f"{BASE}/nouveau/desactiver", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + assert resp.json()["active"] is False + + # Connexion refusée, ancienne session native refusée, + # jti draco révoqué et publié dans la liste des révocations. + assert _login(machine_client, "nouveau", + "motdepasse-initial").status_code == 401 + me = machine_client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token_natif}"}) + assert me.status_code == 401 + assert draco_session.verify_token(token_draco) is None + revocations = machine_client.get( + "/api/sso/revocations", + headers={"X-Draco-Machine-Key": "cle-sso-test"}) + assert revocations.status_code == 200 + assert jti in [r["jti"] for r in revocations.json()["revoked"]] + + resp = machine_client.post(f"{BASE}/nouveau/reactiver", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + assert resp.json()["active"] is True + + # Réactivation : nouvelle connexion OK, ancien jti toujours révoqué. + assert _login(machine_client, "nouveau", + "motdepasse-initial").status_code == 200 + assert draco_session.verify_token(token_draco) is None + + +# ── Réinitialisation du mot de passe ──────────────────────────────────── + +class TestReinitialisationMotDePasse: + def test_ancien_refuse_nouveau_accepte_changement_force( + self, machine_client): + assert _creer(machine_client).status_code == 201 + resp = machine_client.post( + f"{BASE}/nouveau/reinitialiser-mot-de-passe", + headers=MACHINE_HEADERS, + json={"password": "nouveau-mot-de-passe"}) + assert resp.status_code == 200 + assert resp.json()["must_change_password"] is True + + assert _login(machine_client, "nouveau", + "motdepasse-initial").status_code == 401 + login = _login(machine_client, "nouveau", "nouveau-mot-de-passe") + assert login.status_code == 200 + assert login.json()["user"]["must_change_password"] is True + + # Le changement effectif par l'utilisateur efface le marqueur. + token = login.json()["access_token"] + changement = machine_client.post( + "/api/auth/change-password", + headers={"Authorization": f"Bearer {token}"}, + json={"current_password": "nouveau-mot-de-passe", + "new_password": "mot-de-passe-choisi"}) + assert changement.status_code == 200, changement.text + login2 = _login(machine_client, "nouveau", "mot-de-passe-choisi") + assert login2.status_code == 200 + assert login2.json()["user"]["must_change_password"] is False + + +# ── Réinitialisation du MFA ───────────────────────────────────────────── + +class TestReinitialisationMfa: + def test_mfa_reinitialise(self, machine_client): + # Avant : la connexion exige le second facteur. + login = _login(machine_client, "mfauser", "motdepasse-mfa") + assert login.status_code == 200 + assert login.json().get("mfa_required") is True + + resp = machine_client.post(f"{BASE}/mfauser/reinitialiser-mfa", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + assert resp.json()["mfa_enabled"] is False + + # Après : connexion directe, sans second facteur. + login = _login(machine_client, "mfauser", "motdepasse-mfa") + assert login.status_code == 200 + assert "access_token" in login.json() + + +# ── Suppression ───────────────────────────────────────────────────────── + +class TestSuppression: + def test_suppression(self, machine_client): + assert _creer(machine_client).status_code == 201 + resp = machine_client.delete(f"{BASE}/nouveau", + headers=MACHINE_HEADERS) + assert resp.status_code == 200 + assert machine_client.get( + f"{BASE}/nouveau", headers=MACHINE_HEADERS).status_code == 404 + assert _login(machine_client, "nouveau", + "motdepasse-initial").status_code == 401 + + def test_suppression_inconnue_404(self, machine_client): + resp = machine_client.delete(f"{BASE}/personne", + headers=MACHINE_HEADERS) + assert resp.status_code == 404 + + +# ── Journalisation ────────────────────────────────────────────────────── + +class TestJournalisation: + def test_actions_consignees(self, machine_client): + assert _creer(machine_client).status_code == 201 + machine_client.post(f"{BASE}/nouveau/desactiver", + headers=MACHINE_HEADERS) + + audit_file = Path("data/audit.log") + assert audit_file.exists() + entrees = [json.loads(ligne) + for ligne in audit_file.read_text( + encoding="utf-8").splitlines() if ligne.strip()] + actions = {(e["action"], e["cible"]) for e in entrees + if e.get("cible") == "nouveau"} + assert ("compte_cree", "nouveau") in actions + assert ("compte_desactive", "nouveau") in actions + for e in entrees: + if e.get("cible") == "nouveau": + assert e["acteur"] == "hub-test" + assert e["timestamp"]