Compare commits

...
21 Commits
Author SHA1 Message Date
bruno a3f76e9b0c Merge pull request 'ObsiGate 2.85.0 — HUB-22 : API machine des sessions draco (liste et révocation, #227)' (#68) from hub22-sessions-machine into main
CI / lint (push) Failing after 11s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 24s
2026-11-06 11:25:26 -05:00
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
17 changed files with 648 additions and 15 deletions
+13 -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.84.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.85.0**.
---
@@ -14,6 +14,18 @@ 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`.
+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.84.0-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.84.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.0).
---
*Projet : ObsiGate | Version : 2.84.0 | 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.84.0-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.84.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.0).
---
*Project: ObsiGate | Version: 2.84.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.85.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.84.0
2.85.0
+50
View File
@@ -25,6 +25,10 @@
# 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
@@ -179,6 +183,52 @@ def revoke_all_for_user(sub: str) -> int:
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]:
+75 -2
View File
@@ -7,6 +7,12 @@
# 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
@@ -36,6 +42,7 @@ 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
@@ -122,6 +129,16 @@ def _compte_ou_404(username: str) -> dict:
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.
@@ -132,8 +149,7 @@ def _revoquer_sessions(user: dict) -> int:
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)
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
def _audit(request: Request, acteur: str, action: str, cible: str,
@@ -349,3 +365,60 @@ async def reinitialiser_mfa(username: str, request: Request,
_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.84.0"
version = "2.85.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.84.0"
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.84.0",
"version": "2.85.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+19 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.84.0 | **Dernière mise à jour :** 2026-11-06
> **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,24 @@
## 🔵 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)
+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) {
+135
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. */
@@ -695,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();
@@ -709,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.84.0",
"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": {
+157
View File
@@ -354,6 +354,163 @@ class TestSuppression:
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:
+20
View File
@@ -239,3 +239,23 @@ def test_barre_v041_correctif_icones_espace_de_noms_svg():
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")