Merge PR #66 : ObsiGate 2.84.0 — HUB-13 API admin des comptes (go Bruno)
This commit is contained in:
+7
-1
@@ -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 `<svg>` 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`.
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.83.5).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.84.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.83.5 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.84.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.83.5).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.84.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.83.5 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.84.0 | Last updated: September 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.83.5"
|
||||
version = "2.84.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.83.5"
|
||||
version = "2.84.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.83.5",
|
||||
"version": "2.84.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+19
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.83.5",
|
||||
"version": "2.84.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user