Compare commits

...
Author SHA1 Message Date
Spark c157a22668 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227)
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m34s
CI / build (pull_request) Successful in 17s
CI / e2e (pull_request) Failing after 16m29s
2026-11-06 11:08:04 -05:00
Spark cc3707be7a feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:08:01 -05:00
Spark 75c6f61235 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:58 -05:00
Spark 8d7692c6df feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:55 -05:00
Spark 318a20c652 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:51 -05:00
Spark 006cbeed64 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:46 -05:00
Spark 92ad47fc99 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:43 -05:00
Spark 84648591ea feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:39 -05:00
Spark 308f99cf63 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:35 -05:00
Spark 3bfda066ab feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:31 -05:00
Spark 0230f4fa72 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:30 -05:00
Spark 9f6f366870 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:27 -05:00
Spark 44a8154cae feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:24 -05:00
Spark 222cd0b562 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:22 -05:00
Spark 5127510509 revert: HUB-22 — retrait du fichier poussé par erreur sur main (le PUT Contents a ignoré new_branch_name ; la livraison repart en branche hub22-sessions-machine, #227)
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 16s
CI / test (push) Successful in 4m42s
CI / build (push) Successful in 13s
CI / e2e (push) Failing after 14m59s
2026-11-06 11:06:56 -05:00
Spark 3876f0e9c3 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227)
CI / lint (push) Successful in 1m18s
CI / security (push) Successful in 16s
CI / test (push) Successful in 3m35s
CI / build (push) Successful in 14s
CI / e2e (push) Failing after 15m32s
2026-11-06 11:06:00 -05:00
bruno c8d2eb19db Merge pull request 'ObsiGate 2.84.1 — HUB-18 : thème commun, barre v0.5.0 + initialisation thème Hub (fusion #132)' (#67) from hub18-theme-commun into main
CI / lint (push) Successful in 1m34s
CI / security (push) Successful in 28s
CI / test (push) Successful in 4m49s
CI / build (push) Successful in 10s
CI / e2e (push) Failing after 14m54s
2026-11-06 09:45:58 -05:00
Spark 2943e40e81 feat: HUB-18 thème commun — barre v0.5.0 + init thème Hub (2.84.1, fusion #132)
CI / lint (pull_request) Successful in 1m16s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m38s
CI / build (pull_request) Successful in 13s
CI / e2e (pull_request) Failing after 15m2s
2026-11-06 14:22:45 +00:00
bruno 128deb248d Merge pull request 'ObsiGate 2.84.0 — HUB-13 : API d'administration des comptes machine-à-machine (fusion #127)' (#66) from hub13-api-admin-comptes-2-84-0 into main
CI / lint (push) Successful in 1m15s
CI / security (push) Successful in 15s
CI / test (push) Successful in 3m45s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 14m48s
2026-11-06 08:40:24 -05:00
Spark 151d9faeab chore: relance de la vérification de la PR (commit vide, aucun changement de code)
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m46s
CI / build (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 15m53s
2026-11-06 13:34:29 +00:00
bruno c335f57ea2 Merge PR #66 : ObsiGate 2.84.0 — HUB-13 API admin des comptes (go Bruno)
CI / lint (push) Successful in 1m21s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m49s
CI / build (push) Successful in 15s
CI / e2e (push) Successful in 15m8s
2026-11-05 22:30:21 -05:00
Spark c27fd977a8 ObsiGate 2.84.0 : version et changelog (HUB-13, fusion #127) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m23s
CI / security (pull_request) Successful in 23s
CI / test (pull_request) Successful in 3m34s
CI / build (pull_request) Successful in 1m32s
CI / e2e (pull_request) Successful in 15m8s
2026-11-06 00:58:19 +00:00
Spark 65d01f91b8 ObsiGate 2.84.0 : API d'administration des comptes machine-à-machine (HUB-13, fusion #127, #226) — routes /api/admin/machine/comptes protégées par DRACO_ADMIN_MACHINE_KEY, révocation des jti draco à la désactivation, must_change_password, journalisation d'audit 2026-11-06 00:58:16 +00:00
bruno 0427c00015 Merge pull request 'ObsiGate 2.83.5 — barre v0.4.1 : correctif des icônes invisibles du menu compte (#125)' (#65) from hub12-barre-v041 into main
CI / lint (push) Successful in 2m28s
CI / security (push) Successful in 25s
CI / test (push) Successful in 3m34s
CI / build (push) Successful in 9s
CI / e2e (push) Successful in 16m51s
2026-11-05 02:09:20 -05:00
Spark b83de53447 ObsiGate 2.83.5 : barre v0.4.1 — menu compte resserré à 232 px, re-vendange verbatim (fusion #125)
CI / lint (pull_request) Successful in 1m21s
CI / security (pull_request) Successful in 28s
CI / test (pull_request) Successful in 5m18s
CI / build (pull_request) Successful in 15s
CI / e2e (pull_request) Successful in 16m40s
2026-11-05 07:08:02 +00:00
Spark 5eba3e2558 ObsiGate 2.83.5 : version et changelog (barre v0.4.1, fusion #125) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m57s
CI / build (pull_request) Successful in 10s
CI / e2e (pull_request) Successful in 17m0s
2026-11-05 05:53:14 +00:00
Spark 10aa88e03c ObsiGate 2.83.5 : barre v0.4.1 — correctif des icônes invisibles du menu compte, vendange verbatim (fusion #125) + garde 2026-11-05 05:53:05 +00:00
bruno e0e0f9af0f Merge pull request 'ObsiGate 2.83.4 — barre v0.4.0 : icônes monoline dans le menu compte (HUB-11)' (#64) from hub11-barre-v040 into main
CI / lint (push) Successful in 1m24s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m35s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 16m38s
2026-11-05 00:33:34 -05:00
23 changed files with 1658 additions and 19 deletions
+25 -1
View File
@@ -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.4**.
> [Unreleased](#unreleased). La dernière version livrée est **2.85.0**.
---
@@ -14,6 +14,30 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.85.0] — 2026-11-06
- **HUB-22 — API machine des sessions draco (vague C du plan Hub v2.1, #227)** : trois nouvelles routes sous la garde machine existante (`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` / `X-Draco-Actor` ; 401 sans clé valide, 404 si l'API n'est pas configurée ou le compte inconnu) — `GET /api/admin/machine/comptes/{username}/sessions` liste les sessions actives du compte (`jti`, `exp`, `expire_le` en ISO 8601 UTC, `revoquee`), `POST …/sessions/{jti}/revoquer` révoque une session (404 si le `jti` n'est pas dans l'ensemble actif de ce compte — jamais de révocation cross-utilisateur) et `POST …/sessions/revoquer-toutes` révoque toutes les sessions (déconnexion globale, **sans désactiver le compte**). Backend : `draco_session.issued_sessions_for_user()` (sessions actives triées par expiration, purge des expirés au passage) et `revoke_one_for_user()` (appartenance au `sub` vérifiée avant révocation), sur le registre des émissions de HUB-13 et la même clé d'identité (`id`, à défaut `username`). Écritures journalisées dans `data/audit.log` (`session_revoquee`, `sessions_revoquees`). Fiche : `docs/features/hub-sessions-machine-227.md`. Tests : `tests/test_comptes_machine.py` (`TestSessions`), `tests/test_draco_session.py`.
---
## [2.84.1] — 2026-11-06
- **HUB-18 — Thème commun (fusion #132)** : barre commune re-vendangée **verbatim** en **v0.5.0** depuis dracodev-commons (branche `hub18-theme-commun`, PR commons #12) — `frontend/draco-barre.js` seul change côté barre (CSS et fragment inchangés). La barre apprend la préférence de thème du profil du Hub (`GET <hub>/api/profil/theme`, cookie draco ; `<hub>` = URL résolue de la barre, jamais figée) et l'applique à l'ouverture : cache `localStorage` `draco-theme-hub` (TTL 12 h) appliqué synchrone, puis fetch asynchrone non bloquant ; `sombre`/`clair` posent `data-theme`, `systeme` le retire (`prefers-color-scheme` décide). **Repli intégral** si le Hub ne répond pas, répond non-OK ou `theme: null` : le comportement actuel (choix local, puis système) reste seul maître. Initialisation propre à ObsiGate dans `frontend/app.js` : `initTheme()` écoute l'événement `draco:theme` de la barre et route le mode effectif par `applyTheme()` — icône et libellé du bouton de thème et feuilles highlight.js restent synchronisés, le mode appliqué devient le choix local mémorisé (`obsigate-theme`), et le bouton de bascule reste fonctionnel pour la session. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [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`.
---
## [2.83.4] — 2026-11-05
- **Barre commune v0.4.0 — icônes monoline du menu compte (HUB-11, fusion #125)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.4.0 (branche `menu-compte-icones-v0-4-0`, PR commons #10). Les items du menu compte — **Télécharger**, **À Propos**, **Aides**, **Profil**, **Déconnexion** — reçoivent des **icônes SVG monoline** (contour, trait fin, `currentColor`, ~18 px) dans le style des icônes du rail de la console Spark ; aucun emoji. L'en-tête d'identité reste sans icône ; les liens Hub et le relais « Déconnexion » sont inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.83.4-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.85.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.4).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.0).
---
*Projet : ObsiGate | Version : 2.83.4 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.85.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.83.4-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.85.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.4).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.0).
---
*Project: ObsiGate | Version: 2.83.4 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.85.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.83.4
2.85.0
+29
View File
@@ -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.
+9 -1
View File
@@ -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:
+10 -1
View File
@@ -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(),
+124 -1
View File
@@ -19,6 +19,16 @@
# 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().
# HUB-22 (#227) ajoute la lecture de ce registre
# (issued_sessions_for_user) et la révocation unitaire contrôlée par
# appartenance (revoke_one_for_user), exposées par l'API machine des
# sessions (backend/routers/comptes_machine.py).
from __future__ import annotations
@@ -40,10 +50,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 +120,115 @@ 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)
def issued_sessions_for_user(sub: str) -> list[dict]:
"""Sessions draco actives émises pour ``sub`` (HUB-22, #227).
Retourne ``[{jti, exp, revoquee}]`` triée par ``exp`` croissant,
où ``revoquee`` indique si le ``jti`` figure déjà dans la liste des
révocations. Les entrées expirées de cet utilisateur sont purgées
du registre au passage (même patron que ``_register_issued``).
"""
if not sub:
return []
with _lock:
store = _load_issued()
now = int(time.time())
tokens = store.get(str(sub), {})
actifs = {j: e for j, e in tokens.items() if e > now}
if len(actifs) != len(tokens):
if actifs:
store[str(sub)] = actifs
else:
store.pop(str(sub), None)
_save_issued()
revoques = _load_revoked()
entrees = sorted(actifs.items(), key=lambda kv: kv[1])
return [{"jti": jti, "exp": exp, "revoquee": jti in revoques}
for jti, exp in entrees]
def revoke_one_for_user(sub: str, jti: str) -> bool:
"""Révoque un seul ``jti`` draco de ``sub`` (HUB-22, #227).
Ne révoque (via ``revoke``) que si le ``jti`` appartient bien à
l'ensemble émis de ce ``sub`` et n'est pas expiré — jamais de
révocation d'une session d'un autre utilisateur. Retourne ``True``
si la révocation a eu lieu, ``False`` sinon.
"""
if not sub or not jti:
return False
with _lock:
store = _load_issued()
exp = store.get(str(sub), {}).get(jti)
if exp is None or exp <= int(time.time()):
return False
revoke(jti, exp)
return True
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
@@ -122,7 +243,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:
+2
View File
@@ -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
+424
View File
@@ -0,0 +1,424 @@
# backend/routers/comptes_machine.py
# HUB-13 (fusion #127) — API d'administration des comptes, machine-à-machine.
#
# Le Hub (interface d'administration des utilisateurs) administre les
# comptes dont ObsiGate reste le magasin (décisions D1(a)/D2(a) du plan
# Hub v2) via ces routes, en lecture et en écriture : lister, lire le
# profil étendu, créer, modifier, désactiver/réactiver, supprimer,
# réinitialiser le mot de passe et le MFA.
#
# HUB-22 (#227, vague C du plan Hub v2.1) ajoute, sous la même garde et
# avec la même journalisation, les routes « sessions » : lister les
# sessions draco actives d'un compte, en révoquer une (par son `jti`,
# jamais celle d'un autre compte) ou toutes (déconnexion globale, sans
# désactiver le compte), via le registre des émissions de HUB-13.
#
# Emplacement : ``/api/admin/machine/comptes`` — dans l'espace
# d'administration existant (``/api/admin/…``), avec un segment
# ``machine`` explicite qui le distingue des routes d'administration par
# session (``/api/auth/admin/users``, garde ``require_admin``). Aucune
# session utilisateur — même administratrice — n'ouvre ces routes :
# seule la clé machine dédiée les protège.
#
# Authentification machine (même famille que ``GET /api/sso/revocations``) :
# DRACO_ADMIN_MACHINE_KEY clé dédiée à cette API (en-tête
# ``X-Draco-Machine-Key``). Distincte de
# ``DRACO_SSO_MACHINE_KEY`` : le périmètre
# (écriture de comptes) est plus sensible que
# la seule lecture des révocations (plan §3).
# Sans elle, l'API est inerte (404, comme la
# liste de révocations non configurée).
# X-Draco-Actor identité de service appelante, facultative
# (défaut ``service-machine``) — consignée
# comme acteur dans le journal d'audit.
#
# Chaque action est journalisée côté ObsiGate dans le journal d'audit
# existant (``data/audit.log``, voir backend.audit.log_compte_admin).
from __future__ import annotations
import hmac
import logging
import os
import re
import time
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, validator
from backend import draco_session
from backend.audit import log_compte_admin
from backend.auth.password import validate_password_strength
from backend.auth.user_store import (
create_user,
delete_user,
get_all_users,
get_user,
update_user,
)
from backend.services.net import get_client_ip
logger = logging.getLogger("obsigate.comptes_machine")
router = APIRouter(prefix="/api/admin/machine/comptes", tags=["admin"])
#: Variable d'environnement portant la clé machine dédiée à cette API.
MACHINE_KEY_ENV = "DRACO_ADMIN_MACHINE_KEY"
#: En-tête de la clé machine (même nom que pour /api/sso/revocations).
MACHINE_KEY_HEADER = "X-Draco-Machine-Key"
#: En-tête facultatif : identité de service appelante (journal d'audit).
ACTOR_HEADER = "X-Draco-Actor"
#: Acteur consigné quand l'appelant ne se nomme pas.
DEFAULT_ACTOR = "service-machine"
# ── Garde machine ───────────────────────────────────────────────────────
def require_machine_admin(request: Request) -> str:
"""Exige la clé machine dédiée ; retourne l'identité de service.
Jamais de repli sur une session utilisateur : sans en-tête valide,
la réponse est 401 (ou 404 si l'API n'est pas configurée, patron de
``GET /api/sso/revocations``).
"""
machine_key = os.environ.get(MACHINE_KEY_ENV, "")
if not machine_key:
raise HTTPException(404, "Non configuré")
provided = request.headers.get(MACHINE_KEY_HEADER, "")
if not provided or not hmac.compare_digest(provided, machine_key):
raise HTTPException(401, "Clé machine invalide")
actor = (request.headers.get(ACTOR_HEADER, "") or "").strip()
return actor[:64] if actor else DEFAULT_ACTOR
# ── Profil étendu (liste blanche — jamais de secret) ────────────────────
def _profil(user: dict) -> dict:
"""Profil étendu d'un compte, sûr à exposer à un service.
Liste blanche stricte : ni ``password_hash``, ni secret TOTP
(``mfa_secret``/``mfa_secret_pending``), ni codes de récupération,
ni identifiants WebAuthn ne sortent — seul l'état MFA (oui/non)
est exposé. L'avatar suit le modèle existant : la valeur stockée
(data-URL ou ``None``, comme ``GET /api/auth/me``) plus le booléen
de présence ``avatar_present`` pour les listes légères.
"""
return {
"id": user.get("id"),
"username": user.get("username"),
"display_name": user.get("display_name"),
"language": user.get("language", "fr"),
"avatar": user.get("avatar"),
"avatar_present": bool(user.get("avatar")),
"role": user.get("role"),
"active": bool(user.get("active")),
"vaults": user.get("vaults", []),
"mfa_enabled": bool(user.get("mfa_enabled")),
"mfa_method": user.get("mfa_method"),
"must_change_password": bool(user.get("must_change_password")),
"created_at": user.get("created_at"),
"last_login": user.get("last_login"),
}
def _compte_ou_404(username: str) -> dict:
user = get_user(username)
if user is None:
raise HTTPException(404, "Compte introuvable")
return user
def _identifiant_sessions(user: dict) -> str:
"""Clé du registre des émissions draco pour un compte.
C'est le ``sub`` des jetons draco : l'identifiant du compte
(``id``), à défaut son nom d'utilisateur — exactement la clé
qu'utilise ``_revoquer_sessions`` depuis HUB-13.
"""
return str(user.get("id") or user.get("username") or "")
def _revoquer_sessions(user: dict) -> int:
"""Révoque les sessions d'un compte ; retourne le nombre de jti draco.
Les ``jti`` du cookie partagé émis pour ce compte sont révoqués via
le mécanisme existant (``draco_session.revoke``, exposé aux autres
applications par ``GET /api/sso/revocations``). Les jetons natifs
sont invalidés par le garde ``password_changed_at`` (BUG-028),
avancé par l'appelant (désactivation, réinitialisation du mot de
passe) ou par le drapeau ``active`` tant que le compte est inactif.
"""
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
def _audit(request: Request, acteur: str, action: str, cible: str,
details: dict | None = None) -> None:
log_compte_admin(acteur, action, cible,
ip=get_client_ip(request), details=details)
# ── Modèles de requête ──────────────────────────────────────────────────
class CreerCompteRequest(BaseModel):
username: str
password: str
display_name: str | None = None
role: str = "user"
vaults: list[str] = []
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
@validator("username")
def username_valid(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
raise ValueError("2-32 caractères alphanumériques, _ ou -")
return v.lower()
@validator("role")
def role_valid(cls, v):
if v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
class ModifierCompteRequest(BaseModel):
display_name: str | None = None
role: str | None = None
language: str | None = None
vaults: list[str] | None = None
@validator("role")
def role_valid(cls, v):
if v is not None and v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
@validator("language")
def language_valid(cls, v):
if v is not None and v not in ("fr", "en"):
raise ValueError("Langue invalide (fr | en)")
return v
class ReinitialiserMotDePasseRequest(BaseModel):
password: str
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
# ── Lecture ─────────────────────────────────────────────────────────────
@router.get("")
async def lister_comptes(acteur: str = Depends(require_machine_admin)):
"""Liste tous les comptes (profils étendus, sans aucun secret)."""
comptes = [_profil(u) for u in get_all_users()]
return {"comptes": comptes, "total": len(comptes)}
@router.get("/{username}")
async def lire_compte(username: str,
acteur: str = Depends(require_machine_admin)):
"""Lit un compte et son profil étendu."""
return _profil(_compte_ou_404(username))
# ── Écriture ────────────────────────────────────────────────────────────
@router.post("", status_code=201)
async def creer_compte(req: CreerCompteRequest, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Crée un compte. Le mot de passe initial est temporaire :
``must_change_password`` est posé (changement forcé signalé à la
première connexion, comme après une réinitialisation)."""
if get_user(req.username) is not None:
raise HTTPException(409, "Identifiant déjà utilisé")
try:
user = create_user(
req.username, req.password, req.role, req.vaults,
req.display_name, must_change_password=True,
)
except ValueError as e:
raise HTTPException(409, str(e)) from e
# #194 : dossier personnel — même provisionnement que la création
# par session administratrice (best effort).
from backend.user_home import ensure_user_home
await ensure_user_home(req.username)
fresh = get_user(req.username)
if fresh:
user = fresh
_audit(request, acteur, "compte_cree", req.username,
{"role": req.role})
return _profil(user)
@router.patch("/{username}")
async def modifier_compte(username: str, req: ModifierCompteRequest,
request: Request,
acteur: str = Depends(require_machine_admin)):
"""Modifie un compte (nom affiché, rôle global, langue, vaults)."""
_compte_ou_404(username)
updates = req.dict(exclude_none=True)
if not updates:
raise HTTPException(400, "Aucun champ à modifier")
updated = update_user(username, updates)
_audit(request, acteur, "compte_modifie", username,
{"champs": sorted(updates.keys())})
return _profil(updated)
@router.post("/{username}/desactiver")
async def desactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Désactive un compte : plus aucun nouveau jeton n'est émis
(connexion refusée) et les ``jti`` draco actifs sont révoqués."""
user = _compte_ou_404(username)
updated = update_user(username, {
"active": False,
# BUG-028 : avancer le repère invalide aussi les jetons natifs
# déjà émis — ils ne ressuscitent pas à la réactivation.
"password_changed_at": time.time(),
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "compte_desactive", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reactiver")
async def reactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Réactive un compte désactivé (les anciennes sessions restent
révoquées : une nouvelle connexion est nécessaire)."""
_compte_ou_404(username)
updated = update_user(username, {"active": True})
_audit(request, acteur, "compte_reactive", username)
return _profil(updated)
@router.delete("/{username}")
async def supprimer_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Supprime un compte et révoque ses ``jti`` draco actifs."""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
try:
delete_user(username)
except ValueError as e:
raise HTTPException(404, str(e)) from e
# #194 : fermer le vault du dossier personnel (dossier conservé),
# comme la suppression par session administratrice.
from backend.user_home import release_user_home
await release_user_home(username)
_audit(request, acteur, "compte_supprime", username,
{"jti_revoques": nb_jti})
return {"message": f"Compte '{username}' supprimé"}
@router.post("/{username}/reinitialiser-mot-de-passe")
async def reinitialiser_mot_de_passe(
username: str, req: ReinitialiserMotDePasseRequest,
request: Request, acteur: str = Depends(require_machine_admin)):
"""Pose un nouveau mot de passe temporaire : changement forcé à la
première connexion (``must_change_password``, signalé par la
connexion et ``GET /api/auth/me``, effacé au changement effectif).
Les sessions existantes sont révoquées (repère BUG-028 + ``jti``
draco). Le mot de passe n'est jamais journalisé."""
user = _compte_ou_404(username)
updated = update_user(username, {
"password": req.password,
"must_change_password": True,
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "mot_de_passe_reinitialise", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reinitialiser-mfa")
async def reinitialiser_mfa(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Efface l'enrôlement TOTP d'un compte (utilisateur verrouillé
hors de son second facteur) : secret, secret en attente et codes
de récupération sont supprimés. Les clés WebAuthn éventuelles sont
conservées et gardent alors le MFA actif. Le secret n'est jamais
exposé — ni avant, ni après."""
user = _compte_ou_404(username)
webauthn_restantes = len(user.get("webauthn_credentials") or [])
updates: dict = {
"mfa_secret": None,
"mfa_secret_pending": None,
"mfa_recovery_codes": [],
}
if webauthn_restantes:
updates["mfa_method"] = "webauthn"
else:
updates["mfa_enabled"] = False
updates["mfa_method"] = None
updated = update_user(username, updates)
_audit(request, acteur, "mfa_reinitialise", username,
{"webauthn_conserves": webauthn_restantes})
return _profil(updated)
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
@router.get("/{username}/sessions")
async def lister_sessions(username: str,
acteur: str = Depends(require_machine_admin)):
"""Liste les sessions draco actives d'un compte.
Une session par ``jti`` émis et non expiré (registre des émissions
de HUB-13), triées par expiration croissante ; ``revoquee`` signale
un ``jti`` déjà présent dans la liste des révocations et
``expire_le`` est l'échéance ``exp`` en ISO 8601 UTC.
"""
user = _compte_ou_404(username)
sessions = [{
"jti": s["jti"],
"exp": s["exp"],
"expire_le": datetime.fromtimestamp(s["exp"], tz=UTC).isoformat(),
"revoquee": s["revoquee"],
} for s in draco_session.issued_sessions_for_user(
_identifiant_sessions(user))]
return {"username": username, "sessions": sessions}
@router.post("/{username}/sessions/{jti}/revoquer")
async def revoquer_session(username: str, jti: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque une seule session draco d'un compte, par son ``jti``.
404 si le ``jti`` ne figure pas dans l'ensemble actif de ce
compte : une session d'un autre utilisateur n'est jamais révoquée
par cette route (aucune révocation cross-utilisateur).
"""
user = _compte_ou_404(username)
if not draco_session.revoke_one_for_user(
_identifiant_sessions(user), jti):
raise HTTPException(404, "Session introuvable")
_audit(request, acteur, "session_revoquee", username, {"jti": jti})
return {"jti": jti, "revoquee": True}
@router.post("/{username}/sessions/revoquer-toutes")
async def revoquer_toutes_sessions(
username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque toutes les sessions draco actives d'un compte.
C'est la « déconnexion globale » : contrairement à la
désactivation, le compte reste actif — seule l'émission d'une
nouvelle session (nouvelle connexion) rouvre l'accès.
"""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "sessions_revoquees", username,
{"jti_revoques": nb_jti})
return {"revoquees": nb_jti}
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.83.4"
version = "2.85.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.83.4"
version = "2.85.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.83.4",
"version": "2.85.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+37 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.83.4 | **Dernière mise à jour :** 2026-11-05
> **Version :** 2.85.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,42 @@
## 🔵 En cours
### 227. Hub HUB-22 — API machine des sessions draco (liste et révocation)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/hub-sessions-machine-227.md](./features/hub-sessions-machine-227.md)
- **Description :** vague C du plan Hub v2.1 (HUB-22) : le Hub doit lister
les sessions actives d'un utilisateur et les révoquer une à une ou
toutes, via le canal `jti` livré en HUB-13 (#226). Trois routes sous
la même garde machine (`require_machine_admin`) et la même
journalisation d'audit que les routes comptes existantes :
`GET /api/admin/machine/comptes/{username}/sessions`,
`POST …/sessions/{jti}/revoquer` (jamais de révocation
cross-utilisateur) et `POST …/sessions/revoquer-toutes`
(déconnexion globale, sans désactiver le compte).
- **Sous-tâches :**
- [x] `issued_sessions_for_user` + `revoke_one_for_user` (`backend/draco_session.py`)
- [x] Trois routes sessions (`backend/routers/comptes_machine.py`)
- [x] Tests étendus (`tests/test_comptes_machine.py`, `tests/test_draco_session.py`)
### 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)
+105
View File
@@ -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.
+85
View File
@@ -0,0 +1,85 @@
# #227 — HUB-22 : API machine des sessions draco (liste et révocation)
**Carte :** vague C du plan Hub v2.1 (HUB-22) — le Hub consomme ces
routes en 0.17.0 pour l'écran des sessions d'un utilisateur.
**Version :** 2.85.0. **Prérequis :** HUB-13 (#226) — registre des
émissions `jti` et garde machine.
## Objet
Le Hub doit lister les sessions actives d'un utilisateur et les
révoquer une à une ou toutes, via le canal `jti` livré en HUB-13.
Aucune route machine « sessions » n'existait : cette livraison la crée,
sous la même garde (`require_machine_admin`, clé
`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` /
`X-Draco-Actor`) et avec la même journalisation d'audit que les routes
comptes existantes. Sans la clé configurée, l'API reste inerte (404) ;
sans clé valide, 401.
## Routes — `/api/admin/machine/comptes/{username}/sessions`
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/api/admin/machine/comptes/{username}/sessions` | Sessions actives du compte |
| POST | `/api/admin/machine/comptes/{username}/sessions/{jti}/revoquer` | Révocation d'une session |
| POST | `/api/admin/machine/comptes/{username}/sessions/revoquer-toutes` | Révocation de toutes les sessions |
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
configurée), 404 `jti` absent de l'ensemble actif du compte.
### Liste — `GET …/sessions`
`200 {"username": …, "sessions": [{"jti", "exp", "expire_le",
"revoquee"}]}` — une entrée par `jti` émis et non expiré, triée par
`exp` croissant ; `expire_le` est l'échéance en ISO 8601 UTC et
`revoquee` signale un `jti` déjà présent dans la liste des
révocations.
### Révocation unitaire — `POST …/sessions/{jti}/revoquer`
`200 {"jti", "revoquee": true}`. Le `jti` n'est révoqué que s'il
appartient à l'ensemble émis du compte visé et n'est pas expiré :
**404 sinon, jamais de révocation cross-utilisateur**.
### Tout révoquer — `POST …/sessions/revoquer-toutes`
`200 {"revoquees": n}` — réutilise `revoke_all_for_user` de HUB-13.
C'est la « déconnexion globale », **distincte de la désactivation** :
le compte reste actif, une nouvelle connexion émet une nouvelle
session.
## Backend — `backend/draco_session.py`
- `issued_sessions_for_user(sub)` : sessions actives
(`exp > maintenant`) du `sub`, `[{jti, exp, revoquee}]` triées par
`exp` croissant ; les entrées expirées du `sub` sont purgées du
registre au passage.
- `revoke_one_for_user(sub, jti)` : révoque via le `revoke()`
existant uniquement si le `jti` appartient à l'ensemble émis de ce
`sub` et n'est pas expiré ; retourne un booléen.
La clé du registre est le même `sub` que celui de HUB-13
(`_identifiant_sessions` dans `comptes_machine.py` : `id` du compte,
à défaut `username`) — factorisée depuis `_revoquer_sessions`, qui
l'utilise aussi désormais.
## Journalisation
Les deux écritures sont consignées dans `data/audit.log` via
`log_compte_admin` : actions `session_revoquee` (détail : le `jti`) et
`sessions_revoquees` (détail : nombre de `jti` révoqués), acteur =
identité de service `X-Draco-Actor`. La lecture suit le patron des
lectures existantes (non journalisée).
## Tests
- `tests/test_draco_session.py` : liste triée et marquage `revoquee`,
purge des expirés, révocation unitaire, refus cross-utilisateur,
refus d'un `jti` expiré ; le registre des émissions est isolé en
fichier temporaire dans la fixture.
- `tests/test_comptes_machine.py` (`TestSessions`) : liste (contenu,
`expire_le` ISO 8601 UTC), liste vide, compte inconnu 404,
révocation unitaire (succès + effet sur `verify_token`),
cross-utilisateur 404 sans effet, tout-révoquer (compte toujours
actif, autres comptes intacts, seconde passe à 0), garde (401 sans
clé, 404 API non configurée), journalisation.
+24
View File
@@ -1214,6 +1214,30 @@
function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
// Thème commun de la suite (HUB-18, fusion #132) : la barre
// commune (barre v0.5.0) applique la préférence du Hub
// (sombre/clair/système) à l'ouverture — directement sur
// data-theme, et via l'événement « draco:theme » pour que le
// mécanisme propre d'ObsiGate suive (icône et libellé du bouton,
// feuilles highlight.js). applyTheme mémorise le mode appliqué
// dans « obsigate-theme » : la préférence du Hub devient le
// choix local courant, et le bouton de bascule reste le maître
// pour la suite de la session (aucune ré-application de la barre
// après l'ouverture). « systeme » arrive déjà résolu en mode
// effectif (detail.mode) par la barre. Repli : si le Hub ne
// répond pas, aucun événement n'arrive et le choix local
// ci-dessus reste seul maître, comme avant.
document.addEventListener("draco:theme", (e) => {
const mode = e && e.detail && e.detail.mode;
if (mode === "dark" || mode === "light") applyTheme(mode);
});
// Si la barre s'est initialisée AVANT ce module (ordre des
// scripts defer/module selon la page), sa préférence déjà
// résolue depuis son cache est reprise ici.
const barre = window.DRACO_BAR;
if (barre && (barre.themeMode === "dark" || barre.themeMode === "light")) {
applyTheme(barre.themeMode);
}
}
function applyTheme(theme) {
+4 -1
View File
@@ -29,6 +29,9 @@
replacées à gauche du libellé par order: -1 (l'item est en
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
Jetons repris du prototype validé Console v7.2.
v0.4.1 (fusion #125) : menu compte resserré — min-width
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
le menu était trop large une fois les icônes visibles).
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien
(aucun attribut data-theme sur <html>). */
@@ -86,7 +89,7 @@
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
min-width: 232px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
+143 -2
View File
@@ -36,6 +36,12 @@
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
* comportement : liens résolus et Déconnexion relayée inchangés.
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
* la barre apprend la préférence de thème du profil du Hub
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
* local court et repli intégral sur le comportement actuel de
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -268,6 +274,131 @@
}
var resolvedUrls = resolveUrls();
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
* La préférence de thème de la suite vit dans le profil du Hub :
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
* authentifié par le cookie draco (credentials: "include").
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
* défaut officiel) — jamais un nom figé de plus.
* Application par le mécanisme data-theme existant des hôtes :
* "sombre" → data-theme="dark", "clair" → data-theme="light",
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
* html:not([data-theme]) du CSS de la barre comme des hôtes).
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
* propre doit suivre (icônes et libellés, feuilles de style,
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
* s'initialise après la barre lit la même information dans
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
* Ordre d'application : 1) le cache local FRAIS est appliqué de
* façon SYNCHRONE à l'initialisation de la barre (script en defer
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
* a changé. À la toute première visite (aucun cache), un bref
* flash du thème local de l'hôte reste possible le temps du fetch :
* c'est le prix du repli — l'état affiché transitoire est le thème
* que l'hôte aurait de toute façon choisi seul, jamais un état
* cassé, et les visites suivantes sont servies par le cache.
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
* jamais le choix local de l'hôte.
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
* existant. Le comportement actuel de l'hôte (son localStorage,
* puis le système) reste alors exactement le maître, comme avant
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
* fetch) ; elle ne ré-applique ensuite que sur changement du
* schéma système, et seulement tant que la préférence résolue est
* "systeme". */
var THEME_CACHE_CLE = "draco-theme-hub";
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
function themeSystemeEstSombre() {
try {
return !!(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) { return false; }
}
function lireCacheTheme() {
try {
var brut = window.localStorage &&
window.localStorage.getItem(THEME_CACHE_CLE);
if (!brut) return null;
var c = JSON.parse(brut);
if (!c || typeof c.ts !== "number") return null;
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
return c.theme;
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
return null;
}
function ecrireCacheTheme(theme) {
try {
if (window.localStorage) {
window.localStorage.setItem(THEME_CACHE_CLE,
JSON.stringify({ theme: theme, ts: Date.now() }));
}
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
}
function appliquerThemeCommun(theme) {
var root = document.documentElement;
if (!root) return;
if (theme === "sombre" || theme === "clair") {
themeMode = theme === "sombre" ? "dark" : "light";
root.setAttribute("data-theme", themeMode);
} else if (theme === "systeme") {
themeMode = themeSystemeEstSombre() ? "dark" : "light";
root.removeAttribute("data-theme");
} else {
return;
}
themePref = theme;
if (window.DRACO_BAR) {
window.DRACO_BAR.themePref = themePref;
window.DRACO_BAR.themeMode = themeMode;
}
try {
document.dispatchEvent(new CustomEvent("draco:theme",
{ detail: { theme: themePref, mode: themeMode } }));
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
}
function initThemeCommun() {
var enCache = lireCacheTheme();
if (enCache) appliquerThemeCommun(enCache);
/* Suivi du schéma système tant que la préférence est "systeme" :
* sans data-theme, le CSS suit tout seul ; l'événement est
* re-dispatché pour les hôtes qui matérialisent le mode. */
try {
var mq = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)");
if (mq && mq.addEventListener) {
mq.addEventListener("change", function () {
if (themePref === "systeme") appliquerThemeCommun("systeme");
});
}
} catch (e) { /* pas de matchMedia : pas de suivi */ }
if (typeof window.fetch !== "function") return;
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
"api/profil/theme";
window.fetch(url, { credentials: "include" }).then(function (resp) {
if (!resp || !resp.ok) return null;
return resp.json();
}).then(function (data) {
if (!data) return;
var t = data.theme;
if (t === "sombre" || t === "clair" || t === "systeme") {
ecrireCacheTheme(t);
appliquerThemeCommun(t);
}
/* theme null ou inconnu : repli intégral, rien n'est touché. */
}).catch(function () { /* Hub injoignable : repli intégral */ });
}
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -529,11 +660,17 @@
["line", { x1: "21", y1: "12", x2: "9", y2: "12" }]
]
};
/* Espace de noms SVG — OBLIGATOIRE : un élément « svg » (ou
* « path », « circle »…) créé par createElement() appartient à
* l'espace de noms HTML et le navigateur ne le dessine JAMAIS
* (bug v0.4.0 : libellés décalés d'un cran d'icône, aucun glyphe
* visible ; corrigé en v0.4.1, carte fusion #125). */
var SVG_NS = "http://www.w3.org/2000/svg";
function menuIcone(nom) {
var span = document.createElement("span");
span.className = "draco-menu-icone";
span.setAttribute("aria-hidden", "true");
var svg = document.createElement("svg");
var svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
@@ -542,7 +679,7 @@
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("focusable", "false");
(MENU_ICONES[nom] || []).forEach(function (spec) {
var el = document.createElement(spec[0]);
var el = document.createElementNS(SVG_NS, spec[0]);
for (var k in spec[1]) el.setAttribute(k, spec[1][k]);
svg.appendChild(el);
});
@@ -689,6 +826,9 @@
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
themePref: null, themeMode: null,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
@@ -703,6 +843,7 @@
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
initThemeCommun();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.83.4",
"version": "2.85.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": {
+534
View File
@@ -0,0 +1,534 @@
# 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
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
def _emettre(client, username, nombre=1):
"""Émet ``nombre`` jetons draco pour un compte ; retourne
[(jeton, jti, exp), …]."""
from backend import draco_session
profil = client.get(f"{BASE}/{username}",
headers=MACHINE_HEADERS).json()
return [draco_session.issue_token(
{"id": profil["id"], "username": username,
"display_name": profil["display_name"]})
for _ in range(nombre)]
class TestSessions:
def test_liste_sessions(self, machine_client):
from datetime import datetime
assert _creer(machine_client).status_code == 201
emis = _emettre(machine_client, "nouveau", 2)
resp = machine_client.get(f"{BASE}/nouveau/sessions",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "nouveau"
assert len(data["sessions"]) == 2
jtis = [s["jti"] for s in data["sessions"]]
assert jtis == [j for _t, j, _e in emis] # exp croissant
for session, (_t, _j, exp) in zip(data["sessions"], emis):
assert session["exp"] == exp
assert session["revoquee"] is False
echeance = datetime.fromisoformat(session["expire_le"])
assert echeance.tzinfo is not None
assert int(echeance.timestamp()) == exp
def test_liste_sessions_vide_et_compte_inconnu(self, machine_client):
resp = machine_client.get(f"{BASE}/mfauser/sessions",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"username": "mfauser", "sessions": []}
resp = machine_client.get(f"{BASE}/personne/sessions",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
def test_revocation_unitaire(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
(t1, jti1, _e1), (t2, _jti2, _e2) = _emettre(
machine_client, "nouveau", 2)
resp = machine_client.post(
f"{BASE}/nouveau/sessions/{jti1}/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"jti": jti1, "revoquee": True}
# La session révoquée est morte, l'autre vit ; la liste la
# signale comme révoquée.
assert draco_session.verify_token(t1) is None
assert draco_session.verify_token(t2) is not None
sessions = machine_client.get(
f"{BASE}/nouveau/sessions", headers=MACHINE_HEADERS).json()
etats = {s["jti"]: s["revoquee"] for s in sessions["sessions"]}
assert etats[jti1] is True
def test_revocation_cross_utilisateur_404(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
(token, jti, _e), = _emettre(machine_client, "nouveau", 1)
# Le jti de « nouveau » demandé sous « mfauser » : 404, et la
# session de « nouveau » reste valide (jamais de révocation
# cross-utilisateur).
resp = machine_client.post(
f"{BASE}/mfauser/sessions/{jti}/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
assert draco_session.verify_token(token) is not None
# Jti inconnu et compte inconnu : 404 également.
resp = machine_client.post(
f"{BASE}/nouveau/sessions/jti-inconnu/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
resp = machine_client.post(
f"{BASE}/personne/sessions/{jti}/revoquer",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
def test_revoquer_toutes_sans_desactiver(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
emis = _emettre(machine_client, "nouveau", 2)
(autre_token, _j, _e), = _emettre(machine_client, "mfauser", 1)
resp = machine_client.post(
f"{BASE}/nouveau/sessions/revoquer-toutes",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"revoquees": 2}
for token, _jti, _exp in emis:
assert draco_session.verify_token(token) is None
# Les sessions des autres comptes ne sont pas touchées.
assert draco_session.verify_token(autre_token) is not None
# Plus rien à révoquer : le registre a été vidé pour ce compte
# (avant toute nouvelle connexion, qui émettrait une session).
resp = machine_client.post(
f"{BASE}/nouveau/sessions/revoquer-toutes",
headers=MACHINE_HEADERS)
assert resp.json() == {"revoquees": 0}
# Déconnexion globale ≠ désactivation : le compte reste actif
# et une nouvelle connexion fonctionne.
profil = machine_client.get(f"{BASE}/nouveau",
headers=MACHINE_HEADERS).json()
assert profil["active"] is True
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 200
def test_sessions_protegees_par_cle_machine(self, machine_client,
monkeypatch):
assert machine_client.get(
f"{BASE}/nouveau/sessions").status_code == 401
assert machine_client.post(
f"{BASE}/nouveau/sessions/un-jti/revoquer").status_code == 401
assert machine_client.post(
f"{BASE}/nouveau/sessions/revoquer-toutes").status_code == 401
monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY")
assert machine_client.get(
f"{BASE}/nouveau/sessions",
headers=MACHINE_HEADERS).status_code == 404
def test_sessions_journalisees(self, machine_client):
assert _creer(machine_client).status_code == 201
(_t, jti, _e), = _emettre(machine_client, "nouveau", 1)
machine_client.post(f"{BASE}/nouveau/sessions/{jti}/revoquer",
headers=MACHINE_HEADERS)
machine_client.post(f"{BASE}/nouveau/sessions/revoquer-toutes",
headers=MACHINE_HEADERS)
audit_file = Path("data/audit.log")
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 ("session_revoquee", "nouveau") in actions
assert ("sessions_revoquees", "nouveau") in actions
# ── 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"]
+33
View File
@@ -226,3 +226,36 @@ def test_barre_v040_icones_monoline_menu_compte():
bloc = css.split(".draco-menu-icone {", 1)[1].split("}", 1)[0]
assert "width: 18px" in bloc
assert "var(--ink-2)" in bloc
def test_barre_v041_correctif_icones_espace_de_noms_svg():
# v0.4.1 (correctif fusion #125) : les icônes du menu compte
# sont créées via createElementNS (espace de noms SVG). En
# v0.4.0, createElement() les créait dans l'espace de noms
# HTML : jamais dessinées par le navigateur, libellés
# décalés d'un cran d'icône sans aucun glyphe.
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert 'createElementNS(SVG_NS, "svg")' in js
assert "createElementNS(SVG_NS, spec[0])" in js
assert 'createElement("svg")' not in js
assert "createElement(spec[0])" not in js
def test_barre_v050_theme_commun_hub18():
# v0.5.0 (HUB-18, fusion #132) : la barre apprend la préférence
# de thème du Hub et l'applique à l'ouverture. La vendange reste
# verbatim commons ; l'initialisation propre à ObsiGate vit dans
# app.js (écouteur draco:theme → applyTheme).
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "api/profil/theme" in js
assert 'credentials: "include"' in js
assert "draco-theme-hub" in js
assert "THEME_CACHE_TTL_MS" in js
assert 'CustomEvent("draco:theme"' in js
assert "themePref" in js and "themeMode" in js
# Aucun nom de Hub figé de plus que les défauts/dérivations
# documentés : l'URL du Hub reste la résolue de la barre.
assert "resolvedUrls.hub" in js
app = (FRONTEND / "app.js").read_text(encoding="utf-8")
assert 'addEventListener("draco:theme"' in app
assert "barre.themeMode" in app
+59
View File
@@ -18,9 +18,13 @@ def _draco_env(monkeypatch, tmp_path):
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
monkeypatch.setattr(draco_session, "REVOKED_FILE",
tmp_path / "draco_revoked.json")
monkeypatch.setattr(draco_session, "ISSUED_FILE",
tmp_path / "draco_issued.json")
draco_session._revoked = None
draco_session._issued = None
yield
draco_session._revoked = None
draco_session._issued = None
# ── Jeton : émission / vérification ─────────────────────────────────────
@@ -232,3 +236,58 @@ def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
headers={"X-Draco-Machine-Key": "cle-machine"})
assert r.status_code == 200
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
# ── Registre des sessions (HUB-22, #227) ────────────────────────────────
AUTRE = {"id": "user-2", "username": "autre", "display_name": "Autre"}
def test_sessions_emises_listees_triees_et_marquees():
_t1, jti1, exp1 = draco_session.issue_token(USER)
t2, jti2, exp2 = draco_session.issue_token(USER)
draco_session.issue_token(AUTRE) # autre sub : exclu
draco_session.revoke(jti2, exp2)
sessions = draco_session.issued_sessions_for_user("admin-1")
assert [s["jti"] for s in sessions] == [jti1, jti2] # exp croissant
assert sessions[0] == {"jti": jti1, "exp": exp1, "revoquee": False}
assert sessions[1] == {"jti": jti2, "exp": exp2, "revoquee": True}
assert draco_session.verify_token(t2) is None # cohérent
def test_sessions_emises_expirees_purgees():
now = int(time.time())
with draco_session._lock:
store = draco_session._load_issued()
store["admin-1"] = {"ancien": now - 10, "frais": now + 600}
assert [s["jti"] for s in
draco_session.issued_sessions_for_user("admin-1")] == ["frais"]
with draco_session._lock:
assert "ancien" not in draco_session._load_issued()["admin-1"]
assert draco_session.issued_sessions_for_user("personne") == []
assert draco_session.issued_sessions_for_user("") == []
def test_revoquer_une_session_appartenante():
token, jti, _exp = draco_session.issue_token(USER)
assert draco_session.revoke_one_for_user("admin-1", jti) is True
assert draco_session.verify_token(token) is None
assert draco_session.is_revoked(jti)
def test_revoquer_une_session_cross_utilisateur_refusee():
token, jti, _exp = draco_session.issue_token(USER)
# Le jti existe, mais pas dans l'ensemble émis de cet autre sub.
assert draco_session.revoke_one_for_user("user-2", jti) is False
assert draco_session.verify_token(token) is not None
assert draco_session.revoke_one_for_user("admin-1", "inconnu") is False
assert draco_session.revoke_one_for_user("", jti) is False
def test_revoquer_une_session_expiree_refusee():
now = int(time.time())
with draco_session._lock:
draco_session._load_issued()["admin-1"] = {"perime": now - 10}
assert draco_session.revoke_one_for_user("admin-1", "perime") is False
assert not draco_session.is_revoked("perime")