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).
|
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
|
> **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
|
## [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`.
|
- **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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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)
|
_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:
|
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
||||||
"""Read the most recent audit log entries.
|
"""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"],
|
"role": user["role"],
|
||||||
"vaults": user["vaults"],
|
"vaults": user["vaults"],
|
||||||
"avatar": user.get("avatar"),
|
"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"),
|
"language": current_user.get("language", "fr"),
|
||||||
"last_login": current_user.get("last_login"),
|
"last_login": current_user.get("last_login"),
|
||||||
"avatar": current_user.get("avatar"),
|
"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"
|
assert user is not None, f"User {current_user['username']} not found"
|
||||||
if not verify_password(req.current_password, user["password_hash"]):
|
if not verify_password(req.current_password, user["password_hash"]):
|
||||||
raise HTTPException(400, "Mot de passe actuel incorrect")
|
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"])
|
updated = get_user(current_user["username"])
|
||||||
result: dict = {"message": "Mot de passe mis à jour"}
|
result: dict = {"message": "Mot de passe mis à jour"}
|
||||||
if updated is not None:
|
if updated is not None:
|
||||||
|
|||||||
@@ -80,8 +80,16 @@ def create_user(
|
|||||||
role: str = "user",
|
role: str = "user",
|
||||||
vaults: list[str] | None = None,
|
vaults: list[str] | None = None,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
|
must_change_password: bool = False,
|
||||||
) -> dict:
|
) -> 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:
|
with _users_lock:
|
||||||
data = _read()
|
data = _read()
|
||||||
if username in data["users"]:
|
if username in data["users"]:
|
||||||
@@ -95,6 +103,7 @@ def create_user(
|
|||||||
"role": role,
|
"role": role,
|
||||||
"vaults": vaults or [],
|
"vaults": vaults or [],
|
||||||
"active": True,
|
"active": True,
|
||||||
|
"must_change_password": bool(must_change_password), # HUB-13
|
||||||
"language": "fr", # default UI language
|
"language": "fr", # default UI language
|
||||||
"avatar": None, # profile picture data-URL (#113)
|
"avatar": None, # profile picture data-URL (#113)
|
||||||
"created_at": datetime.now(UTC).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
# vide = cookie lié à l'hôte, utile en dev)
|
# vide = cookie lié à l'hôte, utile en dev)
|
||||||
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
|
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
|
||||||
# liste de révocation (GET /api/sso/revocations)
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -40,10 +46,12 @@ ALGORITHM = "HS256"
|
|||||||
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
|
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
|
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
|
||||||
REVOKED_FILE = Path("data/draco_revoked.json")
|
REVOKED_FILE = Path("data/draco_revoked.json")
|
||||||
|
ISSUED_FILE = Path("data/draco_issued.json")
|
||||||
ALLOWED_RETURN_SUFFIX = "dracodev.net"
|
ALLOWED_RETURN_SUFFIX = "dracodev.net"
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
|
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
|
||||||
|
_issued: dict[str, dict[str, int]] | None = None # sub -> {jti -> exp}
|
||||||
|
|
||||||
|
|
||||||
def _secret() -> str:
|
def _secret() -> str:
|
||||||
@@ -108,6 +116,69 @@ def revoked_list() -> list[dict]:
|
|||||||
for jti, exp in _load_revoked().items() if exp > now]
|
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 ─────────────────────────────────────────────
|
# ── Émission / vérification ─────────────────────────────────────────────
|
||||||
|
|
||||||
def issue_token(user: dict) -> tuple[str, str, int]:
|
def issue_token(user: dict) -> tuple[str, str, int]:
|
||||||
@@ -122,7 +193,9 @@ def issue_token(user: dict) -> tuple[str, str, int]:
|
|||||||
"exp": exp,
|
"exp": exp,
|
||||||
"jti": jti,
|
"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:
|
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.bookslm_routes import router as bookslm_router
|
||||||
from backend.routers.backups import router as backups_router
|
from backend.routers.backups import router as backups_router
|
||||||
from backend.routers.coffres import router as coffres_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 _load_config
|
||||||
from backend.routers.config import router as config_router
|
from backend.routers.config import router as config_router
|
||||||
from backend.routers.conflicts import router as conflicts_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(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
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(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(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
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]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.83.5"
|
version = "2.84.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.83.5"
|
version = "2.84.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.83.5",
|
"version": "2.84.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+19
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -30,6 +30,24 @@
|
|||||||
|
|
||||||
## 🔵 En cours
|
## 🔵 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
|
### 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)
|
- **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",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"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