Compare commits

..
Author SHA1 Message Date
bruno 4c9bfd576c Merge pull request 'chore(release): version 2.81.0 (Coffre Secrets Phases 2-5)' (#31) from fix/version-2.81.0 into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m49s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:16:49 -04:00
bruno 20898cade7 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5)
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 00:16:25 -04:00
bruno 2c12639499 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:21 -04:00
bruno 7d2b2067ef chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:19 -04:00
bruno 0b52e6ee86 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:16 -04:00
bruno cafac69008 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:12 -04:00
bruno 871875b8e5 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:10 -04:00
bruno e5ae3e48d6 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:08 -04:00
bruno 76de3dd75e chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:05 -04:00
bruno 687427381d chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:03 -04:00
bruno e418d87802 Merge pull request '[Coffre Secrets] Phase 5 — Documentation de deploiement et mise en service' (#30) from feat/coffre-phase5-deploiement into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:15:09 -04:00
bruno a1f3690085 Merge pull request '[Coffre Secrets] Phase 2 — Migration api_keys.json, sauvegarde/restauration KEK, contrat distribution, consommateurs + correctif UI modale' (#29) from feat/coffre-phase2 into main
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:15:06 -04:00
bruno b42265b41e Phase 2 coffre: correctif modale (max-height viewport + scroll interne)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 1m46s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:45:44 -04:00
bruno 0fc7654f6c Phase 2 coffre: cles i18n import (en) 2026-10-27 23:45:38 -04:00
bruno effad97472 Phase 2 coffre: cles i18n import (fr) 2026-10-27 23:45:33 -04:00
bruno 53d98bfffe Phase 2 coffre: variables OBSIGATE_SECRETS_ENABLED/KEK documentees (sans valeur) 2026-10-27 23:45:20 -04:00
bruno 62aa6079be Phases 3-4 coffre: contrat de distribution Spark et consommateurs 2026-10-27 23:45:16 -04:00
bruno 7d48f65ff9 Phase 2 coffre: documentation sauvegarde/restauration KEK 2026-10-27 23:45:13 -04:00
bruno 3acadf6ad7 Phase 2 coffre: bouton Importer l'existant (import puis purge confirmee) 2026-10-27 23:45:10 -04:00
bruno 5492f99f06 Phase 2 coffre: tests migration, restauration vierge, Spark, consommateurs 2026-10-27 23:45:05 -04:00
bruno 71221e055b Phase 2 coffre: migration, consommateur+octrois, contexte Spark imbrique 2026-10-27 23:44:58 -04:00
bruno 86330e8f65 Phase 2 coffre: module migration api_keys.json -> coffre d'instance (import + purge confirmee) 2026-10-27 23:44:43 -04:00
19 changed files with 1123 additions and 20 deletions
+10
View File
@@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation> # DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net # DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée> # DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ──
# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) :
# OBSIGATE_SECRETS_ENABLED=true
# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par
# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note :
# OBSIGATE_KEK=<kek-base64-32-octets>
# … ou fichier 0600, hors dépôt et hors arborescence des vaults :
# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt
# Aucune autre variable ne doit contenir une valeur de secret du coffre.
+5 -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). et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.80.2**. > [Unreleased](#unreleased). La dernière version livrée est **2.81.0**.
--- ---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.81.0] — 2026-10-28
---
## [2.80.2] — 2026-10-27 ## [2.80.2] — 2026-10-27
### Corrigé ### Corrigé
+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. **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.80.2-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.81.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![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 ## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.80.2). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
--- ---
*Projet : ObsiGate | Version : 2.80.2 | Dernière mise à jour : Septembre 2026* *Projet : ObsiGate | Version : 2.81.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. **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.80.2-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.81.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![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 ## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.80.2). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
--- ---
*Project: ObsiGate | Version: 2.80.2 | Last updated: September 2026* *Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.80.2 2.81.0
+306
View File
@@ -0,0 +1,306 @@
"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20).
Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le
coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``).
Format source (constaté dans ``backend/ai.py`` et
``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``.
Seules les entrées dont le nom ressemble à un secret sont migrables :
clés IA et clés d'outils. Les valeurs non secrètes (par exemple
``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais
touchées par la purge.
Règles de sécurité, non négociables :
- Aucune fonction de ce module ne renvoie une valeur, ne la journalise,
ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store).
Les aperçus ne contiennent que des noms, des types et ``has_value``.
- L'import ne supprime rien : il crée des secrets dans le coffre et
laisse ``api_keys.json`` intact. La purge est une étape séparée, qui
exige ``confirmer=True`` et ne supprime que les noms listés.
- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est
signalé dans ``deja_existants`` et la valeur du coffre est conservée.
Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` :
les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``)
lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer
par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le
lecteur concerné sur le coffre rend donc la clé inaccessible à ce
lecteur : la purge reste un choix explicite de l'opérateur, confirmé
deux fois côté interface (import, puis purge).
"""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
from backend.coffre_store import (
ValidationError,
create_coffre,
create_secret,
get_coffre,
get_secret,
resolve_data_dir,
)
logger = logging.getLogger("obsigate.coffre_migration")
_lock = threading.RLock()
COFFRE_INTERNE_SLUG = "obsigate-interne"
COFFRE_INTERNE_NOM = "ObsiGate interne"
# (nom dans api_keys.json, type de secret, service d'affichage).
# Miroir des clés lues par backend/ai.py (clés IA) et par
# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea
# est volontairement absente : ce n'est pas un secret.
CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = (
("DEEPSEEK_API_KEY", "api_key", "DeepSeek"),
("OPENROUTER_API_KEY", "api_key", "OpenRouter"),
("GEMINI_API_KEY", "api_key", "Gemini"),
("NVIDIA_API_KEY", "api_key", "NVIDIA"),
("QWENCLOUD_API_KEY", "api_key", "QwenCloud"),
("XIAOMI_API_KEY", "api_key", "Xiaomi"),
("MISTRAL_API_KEY", "api_key", "Mistral"),
("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"),
("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"),
("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"),
("OBSIGATE_EXA_API_KEY", "api_key", "Exa"),
("OBSIGATE_GITEA_TOKEN", "token", "Gitea"),
("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"),
)
_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES}
def _nom_secret(nom_source: str) -> str:
"""Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE)."""
return nom_source.lower()
def _api_keys_path(base: Path) -> Path:
return base / "api_keys.json"
def _read_api_keys(base: Path) -> dict:
path = _api_keys_path(base)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("api_keys.json illisible (%s) : %s", path, exc)
return {}
return data if isinstance(data, dict) else {}
def _write_api_keys(base: Path, data: dict) -> None:
"""Écriture atomique tmp+rename, fichier final en 0600 (best-effort)."""
path = _api_keys_path(base)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def lister_candidats(data_dir: str | Path | None = None) -> list:
"""Aperçu de migration : métadonnées seules, jamais de valeur.
Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom
dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur
non vide est présente dans le fichier) et ``deja_existant`` (un
secret de ce nom existe déjà dans le coffre d'instance).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
candidats = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
candidats.append(
{
"nom": nom,
"source": source,
"type": type_secret,
"service": service,
"has_value": isinstance(valeur, str) and bool(valeur.strip()),
"deja_existant": (
get_secret(coffre["id"], nom, base) is not None
if coffre is not None
else False
),
}
)
return candidats
def importer_api_keys(
data_dir: str | Path | None = None, cree_par: str = ""
) -> dict:
"""Importe les entrées avec valeur vers le coffre d'instance.
Ne supprime rien dans ``api_keys.json``. Retourne uniquement des
noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``.
Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre
ne peut pas chiffrer (le routeur les traduit en 503).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
if coffre is None:
coffre = create_coffre(
slug=COFFRE_INTERNE_SLUG,
nom=COFFRE_INTERNE_NOM,
proprietaire=(cree_par or "admin").strip(),
perimetre="instance",
description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres: list[str] = []
deja_existants: list[str] = []
sans_valeur: list[str] = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
if not (isinstance(valeur, str) and valeur.strip()):
sans_valeur.append(nom)
continue
if get_secret(coffre["id"], nom, base) is not None:
deja_existants.append(nom)
continue
create_secret(
coffre,
nom,
type_secret,
valeur,
service=service,
note="Migré depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres.append(nom)
return {
"coffre": COFFRE_INTERNE_SLUG,
"migres": migres,
"deja_existants": deja_existants,
"sans_valeur": sans_valeur,
}
def purger_api_keys(
noms: list[str] | None,
data_dir: str | Path | None = None,
confirmer: bool = False,
) -> dict:
"""Supprime du clair les noms listés, après confirmation explicite.
``confirmer`` doit valoir True, sinon refus (jamais automatique).
Seuls les noms migrables sont acceptés ; un nom inconnu refuse
l'opération entière, pour ne jamais supprimer une clé par erreur.
Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs).
"""
if not confirmer:
raise ValidationError(
"Suppression du clair refusée : confirmation explicite requise"
)
demandes = [(n or "").strip().lower() for n in (noms or [])]
# Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom
# de secret (deepseek_api_key) ; tout le reste refuse l'opération.
noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES}
inconnus = [
n
for n in demandes
if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets
]
if inconnus:
raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}")
sources_a_supprimer = set()
for demande in demandes:
for source, _, _ in CLES_MIGRABLES:
if demande in (source.lower(), _nom_secret(source)):
sources_a_supprimer.add(source)
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
supprimes: list[str] = []
absents: list[str] = []
for source in sorted(sources_a_supprimer):
if source in cles:
del cles[source]
supprimes.append(_nom_secret(source))
else:
absents.append(_nom_secret(source))
_write_api_keys(base, cles)
return {"supprimes": supprimes, "absents": absents}
def creer_consommateur_avec_octrois(
nom: str,
octrois_demandes: list[dict[str, Any]] | None,
cree_par: str,
data_dir: str | Path | None = None,
) -> tuple[dict, str, list, list]:
"""Enregistre un consommateur et ses octrois en une seule opération.
Retourne (consommateur public, jeton à usage unique, octrois créés,
erreurs). Une erreur sur un octroi (coffre ou secret introuvable)
n'annule pas le consommateur ni les autres octrois : elle est
signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre
lui-même n'est jamais modifié par cette opération.
"""
from backend.coffre_store import ( # import local : évite un cycle
IntrouvableError,
create_consommateur,
create_octroi,
)
base = resolve_data_dir(data_dir)
consommateur, jeton = create_consommateur(nom, cree_par, base)
crees: list[dict] = []
erreurs: list[dict] = []
for demande in octrois_demandes or []:
slug = str(demande.get("coffre_slug", "") or "")
secret_nom = str(demande.get("secret_nom", "") or "") or None
try:
coffre = get_coffre(slug, base)
if coffre is None:
raise IntrouvableError(f"Coffre introuvable : {slug}")
secret_id = None
if secret_nom is not None:
secret = get_secret(coffre["id"], secret_nom, base)
if secret is None:
raise IntrouvableError(f"Secret introuvable : {secret_nom}")
secret_id = secret["id"]
octroi = create_octroi(
coffre=coffre,
cible_type="consommateur",
cible_id=consommateur["id"],
secret_id=secret_id,
cree_par=cree_par,
expiration=demande.get("expiration"),
data_dir=base,
)
crees.append(octroi)
except (IntrouvableError, ValidationError) as exc:
erreurs.append(
{
"coffre_slug": slug,
"secret_nom": secret_nom,
"erreur": str(exc),
}
)
return consommateur, jeton, crees, erreurs
+128 -3
View File
@@ -19,6 +19,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
utilisé ici, volontairement). utilisé ici, volontairement).
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant - Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
qu'il n'est pas activé, toutes les routes répondent 503. qu'il n'est pas activé, toutes les routes répondent 503.
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
d'instance, en deux temps (import, puis purge confirmée), et
enregistrement d'un consommateur avec ses octrois en un appel.
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
d'erreur restent ceux de la Phase 1b (voir
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
l'état ``acces_requis`` côté Spark).
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,15 +34,22 @@ from __future__ import annotations
import logging import logging
import os import os
from backend.auth.middleware import require_admin, require_auth
from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
from backend.audit import get_recent_entries, log_coffre_event from backend.audit import get_recent_entries, log_coffre_event
from backend.auth.middleware import require_admin, require_auth
from backend.coffre_crypto import ( from backend.coffre_crypto import (
CryptoIndisponibleError, CryptoIndisponibleError,
KEKAbsenteError, KEKAbsenteError,
generate_kek_b64, generate_kek_b64,
) )
from backend.coffre_migration import (
COFFRE_INTERNE_SLUG,
creer_consommateur_avec_octrois,
importer_api_keys,
lister_candidats,
purger_api_keys,
)
from backend.coffre_store import ( from backend.coffre_store import (
CoffreExisteError, CoffreExisteError,
IntrouvableError, IntrouvableError,
@@ -273,6 +288,43 @@ async def api_create_consommateur(
return {**consommateur, "jeton": jeton} return {**consommateur, "jeton": jeton}
@router.post("/api/coffres/consommateurs/avec-octrois")
async def api_create_consommateur_avec_octrois(
body: dict = Body(...), current_user=Depends(require_admin)
):
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
sans annuler le consommateur ni les autres octrois.
"""
_exiger_actives()
try:
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
nom=body.get("nom", ""),
octrois_demandes=body.get("octrois") or [],
cree_par=_username(current_user),
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
for octroi in octrois:
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre_octroi["slug"] if coffre_octroi else None,
resultat="ok",
)
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
@router.delete("/api/coffres/consommateurs/{consommateur_id}") @router.delete("/api/coffres/consommateurs/{consommateur_id}")
async def api_revoke_consommateur( async def api_revoke_consommateur(
consommateur_id: str, current_user=Depends(require_admin) consommateur_id: str, current_user=Depends(require_admin)
@@ -291,6 +343,67 @@ async def api_revoke_consommateur(
return consommateur return consommateur
# ---------------------------------------------------------------------------
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
# L'import ne supprime rien ; la purge exige une confirmation explicite
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
# une valeur, seulement des noms et des comptes.
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
# capturées par le paramètre slug.
# ---------------------------------------------------------------------------
@router.get("/api/coffres/migration/api-keys")
async def api_migration_apercu(current_user=Depends(require_admin)):
_exiger_actives()
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
@router.post("/api/coffres/migration/api-keys/import")
async def api_migration_import(current_user=Depends(require_admin)):
_exiger_actives()
try:
resultat = importer_api_keys(cree_par=_username(current_user))
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
for nom in resultat["migres"]:
_audit(
_username(current_user),
"migration_api_keys",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
@router.post("/api/coffres/migration/api-keys/purger")
async def api_migration_purger(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
if body.get("confirmer") is not True:
raise HTTPException(status_code=400, detail="confirmation_requise")
try:
resultat = purger_api_keys(
noms=body.get("noms") or [], confirmer=True
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for nom in resultat["supprimes"]:
_audit(
_username(current_user),
"migration_api_keys_purge",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM) # Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -299,8 +412,20 @@ async def api_revoke_consommateur(
async def api_distribuer(request: Request, body: dict = Body(...)): async def api_distribuer(request: Request, body: dict = Body(...)):
_exiger_actives() _exiger_actives()
reference = (body.get("reference") or "").strip() reference = (body.get("reference") or "").strip()
contexte_type = (body.get("contexte_type") or "").strip() # Phase 3 (Spark) : le contexte peut arriver à plat
contexte_id = (body.get("contexte_id") or "").strip() # (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
# forme envoyée par le client Spark). Les deux formes sont acceptées.
contexte_type = str(body.get("contexte_type") or "").strip()
contexte_id_raw = body.get("contexte_id")
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
if not contexte_type or not contexte_id:
contexte = body.get("contexte")
if isinstance(contexte, dict):
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
contexte_id_raw = contexte.get("id")
contexte_id = contexte_id or (
"" if contexte_id_raw is None else str(contexte_id_raw).strip()
)
if not contexte_type or not contexte_id: if not contexte_type or not contexte_id:
raise HTTPException(status_code=400, detail="contexte_manquant") raise HTTPException(status_code=400, detail="contexte_manquant")
if not reference.startswith("coffre:") or "/" not in reference[7:]: if not reference.startswith("coffre:") or "/" not in reference[7:]:
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.80.2" version = "2.81.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.80.2" version = "2.81.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.80.2", "version": "2.81.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+156
View File
@@ -0,0 +1,156 @@
# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4)
Référence du contrat pour les clients serveur-à-serveur : Spark
(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne
duplique aucune logique de chiffrement : le chiffrement reste
entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM).
## 1. Endpoint
```
POST /api/coffres/distribuer
Authorization: Bearer <jeton-consommateur>
Content-Type: application/json
```
Corps (forme à plat, Phase 1b) :
```json
{
"reference": "coffre:<slug>/<nom>",
"contexte_type": "mission",
"contexte_id": "mission-123"
}
```
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
```json
{
"reference": "coffre:<slug>/<nom>",
"contexte": { "type": "mission", "id": "mission-123" }
}
```
La forme à plat reste supportée ; si les deux formes sont présentes,
la forme à plat est prioritaire. Le contexte est **obligatoire** :
sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`),
la requête est refusée avant toute authentification utile.
Réponse en succès (200) :
```json
{ "reference": "coffre:<slug>/<nom>", "valeur": "<valeur>", "version": 1 }
```
La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit
jamais être écrite dans un fichier, un journal, un prompt, une étape
d'activité ou un livrable. Le patron imposé à chaque app est
**distribuer → utiliser → oublier** ; à la fin de l'opération
(fin de mission, fin de synchronisation), l'environnement qui la
contenait est détruit et rien ne persiste.
Prérequis côté ObsiGate : coffres activés
(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK
configurée (sinon 503 `kek_absente`).
## 2. Authentification : consommateur nommé, jeton propre
- Un consommateur est créé par un admin :
`POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une
seule opération avec ses octrois :
`POST /api/coffres/consommateurs/avec-octrois`
(`{"nom": "tiktak", "octrois": [{"coffre_slug": "...",
"secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre).
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création.
Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté)
et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que
`id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`).
- L'`id` du consommateur sert de `jti` révocable :
`DELETE /api/coffres/consommateurs/{id}` révoque immédiatement
toutes ses distributions, sans toucher aux autres consommateurs.
- Ce jeton est **distinct** du jeton MCP de notes et de la session
SSO : une session utilisateur, même admin, ne distribue jamais
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
distribution par session (voir §6).
- Ajouter une app = enregistrer un consommateur + créer des octrois,
**sans modification du coffre** et sans toucher à ses secrets.
## 3. Cas Spark (issue #21)
- Consommateur nommé `spark` (convention, non imposée par le code :
l'autorisation porte sur le jeton, pas sur le nom).
- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant
de la mission. La forme imbriquée
`contexte: {type: "mission", id: <id-mission>}` est acceptée.
- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la
correspondance des erreurs) : le LLM ne voit que la référence
`coffre:<slug>/<nom>` et un accès oui/non, jamais la valeur. Sans
octroi, la mission passe en `acces_requis` en nommant l'accès
manquant. Avec octroi et mission approuvée (enveloppe budgétaire),
le service Spark appelle `/distribuer` et injecte la valeur en
variable d'environnement de l'exécuteur, jamais dans le prompt.
Fin de mission = environnement détruit.
- Révocation dans ObsiGate (consommateur ou octroi) : la mission
suivante repasse en `acces_requis`. Limite assumée : une mission
déjà démarrée conserve son environnement jusqu'à sa fin.
## 4. Erreurs (codes `detail`, stables depuis la Phase 1b)
| HTTP | `detail` | Sens | Correspondance Spark |
| --- | --- | --- | --- |
| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. |
| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. |
| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. |
| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). |
| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). |
| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). |
| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). |
| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. |
| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. |
Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du
coffre ; un octroi par secret ne couvre que ce secret. Un octroi
révoqué ne doit jamais être masqué par l'absence d'un autre octroi
(priorité : ok > révoqué > expiré > absent).
## 5. Audit
Chaque distribution est auditée (`distribution_ok` ou
`distribution_refusee`, avec référence, consommateur, contexte et
code de résultat). **Aucune entrée d'audit ne contient jamais une
valeur, un texte chiffré, un nonce ou un jeton.** La route journal
(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur
ces champs même s'ils apparaissaient un jour dans une entrée.
La copie manuelle à usage unique (admin, via consommateur temporaire
créé puis révoqué immédiatement, valeur copiée au presse-papiers
sans affichage) est auditée comme une distribution de contexte
`copie_manuelle`, avec la trace distincte de ce contexte.
## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate)
- **Isolation workspace Flowdeck** : le type de cible `workspace`
existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui
que les octrois de type `consommateur`. L'isolation « un workspace
ne peut pas utiliser l'octroi d'un autre » repose donc, pour
l'instant, sur le serveur Flowdeck lui-même (il ne demande que les
références de son workspace, avec son propre jeton consommateur).
Un contrôle serveur strict (contexte workspace vérifié côté
ObsiGate contre un octroi `workspace`) reste à spécifier et à
implémenter si Bruno l'exige — il changerait la sémantique
d'autorisation de `/distribuer` et doit être coordonné avec les
apps clientes avant tout changement.
- **Distribution par session** : non implémentée, volontairement.
Elle contredirait la règle Phase 1b (distribution réservée aux
consommateurs) et exigerait une ré-authentification qui n'existe
pas encore dans ObsiGate. La copie manuelle reste le patron
supporté (consommateur temporaire, admin seulement).
- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) :
à implémenter dans chaque dépôt, en branche + PR, jamais mergés
sans go. Patron commun : le serveur distribue, utilise, oublie ;
aucune valeur en cache ; si ObsiGate est indisponible, l'opération
échoue proprement, sans repli sur une ancienne valeur.
- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) :
toujours branchés sur `api_keys.json` en clair (voir le document
de sauvegarde/restauration, §5) ; leur branchement sur le coffre
est un travail séparé de la présente Phase 2.
+155
View File
@@ -0,0 +1,155 @@
# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20)
Ce document décrit la seule procédure supportée pour sauvegarder et
restaurer le coffre Secrets, ainsi que le cas de perte de la KEK.
Il complète la spécification du coffre (§13, §14) ; il ne décrit
aucune valeur réelle — les exemples utilisent des valeurs fictives.
## 1. Ce qui constitue une sauvegarde
Deux fichiers, et seulement ces deux fichiers, portent le coffre :
| Fichier | Contenu |
| --- | --- |
| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. |
| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). |
Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** :
copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est
chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son
coffre. Ils peuvent donc transiter par la sauvegarde habituelle du
serveur, avec les mêmes permissions restrictives que sur l'instance
(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans
une note ObsiGate, ni dans un journal.
La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à
part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK
au même endroit annulerait la protection du chiffrement.
## 2. Sauvegarder la KEK (copie externe, hors serveur)
La KEK est une clé maître 256 bits (base64 de 32 octets), générée une
seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres
activés) et **jamais stockée par ObsiGate**. Au moment de sa
génération :
1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou
sur un support hors ligne, hors du serveur ObsiGate.
2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate,
jamais dans le même dossier que `data/`, jamais dans un journal.
3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis
l'écran qui ne l'affichera plus jamais).
Variables d'environnement (documentées aussi dans `.env.example`,
sans aucune valeur) :
| Variable | Rôle | Défaut |
| --- | --- | --- |
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé |
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente |
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent |
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` |
**Aucune autre variable ne doit contenir une valeur de secret du
coffre.** En particulier, ne créez jamais de variable du type
`COFFRE_<nom>` avec une valeur : la seule valeur externe autorisée est
la KEK, et elle n'est pas une valeur de secret du coffre.
## 3. Restauration sur une instance (y compris vierge)
Prérequis : une instance ObsiGate avec le coffre activé
(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance
d'origine.
1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune
écriture n'a lieu pendant la copie).
2. Restaurez les deux fichiers `coffres.json` et
`coffres_valeurs.json` dans le répertoire de données cible, en
permissions 0600.
3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via
`OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt).
4. Redémarrez l'instance.
5. Vérifiez, sans jamais afficher ni journaliser de valeur :
- la vue Secrets liste les coffres et les secrets avec
`has_value = true` ;
- une distribution de test via un consommateur existant (ou un
consommateur de test, révoqué ensuite) aboutit — côté serveur,
le succès seul est vérifié, la valeur n'est ni affichée ni
consignée ;
- le journal d'audit contient `distribution_ok` pour cette
référence.
**Test de restauration obligatoire avant la mise en service réelle
(issue #20)** : la procédure ci-dessus a été automatisée dans la
suite de tests (`tests/test_coffre_secrets.py`, classe
`TestRestaurationVierge`) — copie des deux fichiers vers un
répertoire vierge, même KEK, distributions fonctionnelles — et doit
aussi être rejouée une fois sur une instance réelle par l'opérateur
avant de considérer la sauvegarde comme valide.
## 4. Perte de la KEK : perte d'accès assumée, par construction
Si la KEK est perdue, **il n'existe aucun moyen de récupérer les
valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les
valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM,
authentifié). Il n'y a ni clé de secours, ni dérivation depuis le
secret d'application, ni porte dérobée.
Procédure en cas de perte :
1. Considérez toutes les valeurs du coffre comme perdues.
2. Créez de nouveaux coffres avec une nouvelle KEK.
3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur**
(nouvelle clé API, nouveau jeton) et créez un nouveau secret avec
cette nouvelle valeur ; révoquez les anciennes valeurs côté
fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles
ne sont pas révoquées là-bas.
4. Recréez les consommateurs et les octrois (les anciens octrois
pointent vers des secrets qui n'existent plus dans les nouveaux
coffres).
5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle
KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le
comportement attendu, pas un bug.
La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas
une récupération : elle exige l'ancienne KEK encore valide pour
re-chiffrer les DEK, puis le remplacement de la KEK dans
l'environnement ou le fichier KEK.
## 5. Migration `api_keys.json` (Phase 2) — avertissement important
Le bouton « Importer l'existant » de la vue Secrets (admin seulement)
copie les clés IA et clés d'outils de `data/api_keys.json` vers le
coffre d'instance « ObsiGate interne » (slug `obsigate-interne`),
en deux temps :
1. **Import** : les entrées avec valeur sont chiffrées dans le coffre
(un secret déjà présent n'est jamais écrasé) ; `api_keys.json`
n'est pas modifié.
2. **Purge** : après confirmation explicite, et uniquement pour les
noms migrés, les entrées sont supprimées du clair. Sans
confirmation, rien n'est supprimé — jamais automatiquement.
**Avertissement** : les lecteurs actuels d'ObsiGate
(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore
`api_keys.json` directement ; ils ne sont pas encore branchés sur le
coffre. Purger le clair prive donc ces lecteurs de la clé tant que
leur lecteur n'est pas branché sur une distribution depuis le coffre.
Ne purgez que si vous avez vérifié que le consommateur concerné
distribue depuis le coffre, ou si vous acceptez cette interruption.
Ce branchement des lecteurs internes est un reste à faire explicite
(voir §6).
## 6. Restes à faire (après cette Phase 2)
- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur
une distribution depuis le coffre d'instance, pour que la purge du
clair ne coupe pas l'IA ni les outils.
- Rejouer le test de restauration sur une instance réelle (la suite
automatisée simule une instance vierge, elle ne remplace pas un
essai sur l'instance de production).
- Endpoint de distribution par session utilisateur (copie manuelle
sans consommateur temporaire) : non ajouté ici, car il contredirait
la règle Phase 1b (distribution réservée aux consommateurs) et
exigerait une ré-authentification qui n'existe pas encore — décision
à prendre avec l'opérateur avant toute implémentation.
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.80.2 | **Dernière mise à jour :** 2026-10-27 > **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
+88
View File
@@ -18,6 +18,11 @@
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué * _copieManuelle) passe par un consommateur temporaire créé puis révoqué
* immédiatement, avec distribution auditée ; il n'existe pas encore * immédiatement, avec distribution auditée ; il n'existe pas encore
* d'endpoint de distribution par session utilisateur côté backend. * d'endpoint de distribution par session utilisateur côté backend.
* - Phase 2 (issue #20) : « Importer l'existant » migre les clés de
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
* en deux temps (import, puis purge confirmée). Aucune valeur ne
* transite jamais par cette vue : l'aperçu et les résultats ne
* contiennent que des noms et des comptes (has_value).
* - La vue est rendue directement dans #content-area : elle ne passe ni * - La vue est rendue directement dans #content-area : elle ne passe ni
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur, * par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
* ni par la recherche, ni par le graphe. * ni par la recherche, ni par le graphe.
@@ -865,6 +870,83 @@ async function copieManuelle(s) {
} }
} }
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
* 1. Aperçu (métadonnées seules, jamais de valeur) puis import vers le
* coffre d'instance. L'import ne supprime rien dans api_keys.json.
* 2. Purge du clair, uniquement après confirmation explicite, et
* uniquement pour les noms effectivement migrés. L'avertissement est
* affiché avant l'import : les lecteurs actuels (IA, outils) lisent
* encore api_keys.json, la purge les prive donc de la clé tant que
* leur lecteur n'est pas branché sur le coffre (voir la documentation
* de sauvegarde/restauration). */
async function importerExistant() {
if (!isAdmin()) return;
let apercu;
try {
apercu = await api('/api/coffres/migration/api-keys');
} catch (e) {
showToast(e.message || t('common.error'), 'error');
return;
}
const candidats = apercu.candidats || [];
const lignes = candidats.map((c) => {
const etat = c.deja_existant
? t('coffres.import_already_exists')
: (c.has_value ? t('coffres.import_has_value') : t('coffres.import_no_value'));
return `${c.nom} (${c.service}) — ${etat}`;
});
const body = el('div', {}, [
text('p', t('coffres.import_warning'), 'modal-hint'),
...lignes.map((ligne) => text('p', ligne, '')),
]);
const m = openModal(t('coffres.import_title'), body, [
modalBtn(t('common.cancel'), '', () => m.close()),
modalBtn(t('coffres.import_existing'), 'primary', async () => {
let resultat;
try {
resultat = await api('/api/coffres/migration/api-keys/import', { method: 'POST', body: '{}' });
} catch (e) {
showToast(e.message || t('common.error'), 'error');
return;
}
m.close();
const migres = resultat.migres || [];
const resume = [
`${t('coffres.import_done')} — ${t('coffres.import_migrated')}: ${migres.length}, ${t('coffres.import_already_exists')}: ${(resultat.deja_existants || []).length}, ${t('coffres.import_no_value')}: ${(resultat.sans_valeur || []).length}`,
...migres.map((nom) => `${nom} — ${t('coffres.import_migrated')}`),
];
const rb = el('div', {}, resume.map((ligne) => text('p', ligne, '')));
const boutons = [modalBtn(t('common.close'), 'primary', async () => {
rm.close();
await openCoffresView('obsigate-interne');
})];
if (migres.length) {
boutons.unshift(modalBtn(t('coffres.import_purge'), 'danger', async () => {
const ok = await showConfirm({
title: t('coffres.import_purge'),
message: `${t('coffres.import_purge_confirm')} ${migres.join(', ')}`,
danger: true,
});
if (!ok) return;
try {
const purge = await api('/api/coffres/migration/api-keys/purger', {
method: 'POST',
body: JSON.stringify({ confirmer: true, noms: migres }),
});
showToast(`${t('coffres.import_purged')} (${(purge.supprimes || []).length})`, 'success');
} catch (e) {
showToast(e.message || t('common.error'), 'error');
}
rm.close();
await openCoffresView('obsigate-interne');
}));
}
const rm = openModal(t('coffres.import_title'), rb, boutons);
void rm;
}),
]);
}
function openKekModal(mode) { function openKekModal(mode) {
if (!isAdmin()) return; if (!isAdmin()) return;
if (mode === 'generer') { if (mode === 'generer') {
@@ -1002,6 +1084,12 @@ export async function openCoffresView(slug, options = {}) {
}); });
toolbar.appendChild(search); toolbar.appendChild(search);
toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug))); toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug)));
// Phase 2 : migration api_keys.json -> coffre d'instance (admin seulement).
// Le bouton est visible quel que soit le coffre affiché : la cible est
// toujours le coffre d'instance « ObsiGate interne ».
if (isAdmin()) {
toolbar.appendChild(modalBtn(t('coffres.import_existing'), '', () => importerExistant()));
}
toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => { toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => {
const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true }); const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true });
if (!ok) return; if (!ok) return;
+12 -1
View File
@@ -2757,5 +2757,16 @@
"coffres.type": "Type", "coffres.type": "Type",
"coffres.value": "Value", "coffres.value": "Value",
"coffres.value_required": "A value is required.", "coffres.value_required": "A value is required.",
"coffres.version": "Version" "coffres.version": "Version",
"coffres.import_existing": "Import existing",
"coffres.import_title": "Import existing keys",
"coffres.import_has_value": "Value present",
"coffres.import_no_value": "No value",
"coffres.import_already_exists": "Already in the vault",
"coffres.import_migrated": "Migrated",
"coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.",
"coffres.import_done": "Import finished",
"coffres.import_purge": "Delete plaintext",
"coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:",
"coffres.import_purged": "Plaintext deleted"
} }
+12 -1
View File
@@ -2757,5 +2757,16 @@
"coffres.type": "Type", "coffres.type": "Type",
"coffres.value": "Valeur", "coffres.value": "Valeur",
"coffres.value_required": "Une valeur est requise.", "coffres.value_required": "Une valeur est requise.",
"coffres.version": "Version" "coffres.version": "Version",
"coffres.import_existing": "Importer l'existant",
"coffres.import_title": "Importer les clés existantes",
"coffres.import_has_value": "Valeur présente",
"coffres.import_no_value": "Aucune valeur",
"coffres.import_already_exists": "Déjà dans le coffre",
"coffres.import_migrated": "Migré",
"coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.",
"coffres.import_done": "Import terminé",
"coffres.import_purge": "Supprimer le clair",
"coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :",
"coffres.import_purged": "Clair supprimé"
} }
+16 -2
View File
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 16px;
box-sizing: border-box;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 200ms ease; transition: opacity 200ms ease;
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
min-width: 400px; /* Correctif Phase 2 (modale de création de secret coupée en haut) :
max-width: 600px; sans max-height ni scroll interne, un formulaire long dépassait le
viewport. La modale est désormais bornée au viewport, en colonne
flex, avec seul le corps qui défile ; l'en-tête et le pied restent
toujours visibles. Sur petit écran, la largeur est bornée aussi. */
min-width: min(400px, calc(100vw - 32px));
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
transform: scale(0.9); transform: scale(0.9);
transition: transform 200ms ease; transition: transform 200ms ease;
} }
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-shrink: 0;
} }
.obsigate-modal-title { .obsigate-modal-title {
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
.obsigate-modal-body { .obsigate-modal-body {
padding: 20px; padding: 20px;
overflow-y: auto;
min-height: 0;
} }
.obsigate-modal-footer { .obsigate-modal-footer {
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
flex-shrink: 0;
} }
.modal-input { .modal-input {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.80.2", "version": "2.81.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js", "main": "patch.js",
"directories": { "directories": {
+223
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import os import os
import shutil
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -455,3 +456,225 @@ class TestDistribution:
assert "secret_cree" in actions assert "secret_cree" in actions
assert "distribution_ok" in actions assert "distribution_ok" in actions
assert "valeur" not in json.dumps(resp.json()) assert "valeur" not in json.dumps(resp.json())
# ---------------------------------------------------------------------------
# Phase 2 — Migration api_keys.json (issue #20)
# ---------------------------------------------------------------------------
class TestMigrationApiKeys:
def _ecrire_api_keys(self, tmp_path):
(tmp_path / "api_keys.json").write_text(
json.dumps(
{
"DEEPSEEK_API_KEY": VALEUR_TEST,
"OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2,
"OBSIGATE_GITEA_URL": "https://example.invalid",
"MISTRAL_API_KEY": "",
}
),
encoding="utf-8",
)
def test_apercu_import_purge(self, client, env_coffre):
self._ecrire_api_keys(env_coffre)
resp = client.get("/api/coffres/migration/api-keys")
assert resp.status_code == 200, resp.text
# Aperçu = métadonnées seules, jamais une valeur.
assert VALEUR_TEST not in resp.text
assert VALEUR_TEST_2 not in resp.text
candidats = {c["nom"]: c for c in resp.json()["candidats"]}
assert candidats["deepseek_api_key"]["has_value"] is True
assert candidats["mistral_api_key"]["has_value"] is False
assert "obsigate_gitea_url" not in candidats # URL, pas un secret
# Import : ne supprime rien dans api_keys.json.
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 200, resp.text
resultat = resp.json()
assert resultat["coffre"] == "obsigate-interne"
assert "deepseek_api_key" in resultat["migres"]
assert "obsigate_gitea_token" in resultat["migres"]
assert "mistral_api_key" in resultat["sans_valeur"]
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST
# Les secrets migrés sont visibles en métadonnées et distribuables.
resp = client.get("/api/coffres/obsigate-interne/secrets")
assert resp.status_code == 200
assert VALEUR_TEST not in resp.text
assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json())
_, jeton, _ = _creer_consommateur_octroi(
client, slug="obsigate-interne", nom="deepseek_api_key"
)
resp = _distribuer(
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
# Purge sans confirmation : refusée, fichier intact.
resp = client.post(
"/api/coffres/migration/api-keys/purger",
json={"confirmer": False, "noms": ["deepseek_api_key"]},
)
assert resp.status_code == 400
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert "DEEPSEEK_API_KEY" in cles
# Purge confirmée : seules les clés listées disparaissent.
resp = client.post(
"/api/coffres/migration/api-keys/purger",
json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]},
)
assert resp.status_code == 200, resp.text
assert set(resp.json()["supprimes"]) == {
"deepseek_api_key",
"obsigate_gitea_token",
}
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert "DEEPSEEK_API_KEY" not in cles
assert "OBSIGATE_GITEA_TOKEN" not in cles
assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid"
# Audit sans valeur.
_fichiers_sans_valeur(env_coffre)
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
assert "migration_api_keys" in audit
assert "migration_api_keys_purge" in audit
def test_import_ne_ecrase_pas_existant(self, client, env_coffre):
self._ecrire_api_keys(env_coffre)
assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200
(env_coffre / "api_keys.json").write_text(
json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8"
)
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 200
assert "deepseek_api_key" in resp.json()["deja_existants"]
_, jeton, _ = _creer_consommateur_octroi(
client, slug="obsigate-interne", nom="deepseek_api_key"
)
resp = _distribuer(
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
)
assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée
def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch):
# Une clé à migrer est nécessaire : sans valeur à chiffrer,
# l'import réussit trivialement même sans KEK (mode dégradé).
self._ecrire_api_keys(env_coffre)
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 503
assert resp.json()["detail"] == "kek_absente"
# ---------------------------------------------------------------------------
# Phase 2 — Restauration sur instance vierge (issue #20)
# ---------------------------------------------------------------------------
class TestRestaurationVierge:
def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
# Simulation d'une instance vierge : seuls les deux fichiers sont
# copiés, la KEK (variable d'environnement) est inchangée.
restaure = tmp_path / "restaure"
restaure.mkdir()
for nom in ("coffres.json", "coffres_valeurs.json"):
shutil.copy(env_coffre / nom, restaure / nom)
coffre_r = get_coffre_or_404("personnel", restaure)
assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST
for nom in ("coffres.json", "coffres_valeurs.json"):
assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8")
def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
restaure = tmp_path / "restaure"
restaure.mkdir()
for nom in ("coffres.json", "coffres_valeurs.json"):
shutil.copy(env_coffre / nom, restaure / nom)
# Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur.
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
coffre_r = get_coffre_or_404("personnel", restaure)
with pytest.raises(TagInvalideError):
distribuer_valeur(coffre_r, secret, restaure)
# ---------------------------------------------------------------------------
# Phase 3 — Contexte Spark imbriqué (issue #21)
# ---------------------------------------------------------------------------
class TestDistributionSpark:
def test_contexte_nested_mission(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.post(
"/api/coffres/distribuer",
json={
"reference": "coffre:personnel/gitea-token",
"contexte": {"type": "mission", "id": "mission-test-2"},
},
headers={"Authorization": f"Bearer {jeton}"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
def test_contexte_nested_sans_octroi_acces_requis(self, client):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
jeton = resp.json()["jeton"]
resp = client.post(
"/api/coffres/distribuer",
json={
"reference": "coffre:personnel/gitea-token",
"contexte": {"type": "mission", "id": "mission-test-3"},
},
headers={"Authorization": f"Bearer {jeton}"},
)
# Côté Spark, ce code se traduit par l'état acces_requis.
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_absent"
# ---------------------------------------------------------------------------
# Phase 4 — Consommateur + octrois en un appel (issue #22)
# ---------------------------------------------------------------------------
class TestConsommateurAvecOctrois:
def test_avec_octrois_distribue(self, client, env_coffre):
_creer_coffre_secret(client)
resp = client.post(
"/api/coffres/consommateurs/avec-octrois",
json={
"nom": "tiktak",
"octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}],
},
)
assert resp.status_code == 200, resp.text
corps = resp.json()
assert corps["nom"] == "tiktak"
assert corps["jeton"].startswith("csk_")
assert len(corps["octrois"]) == 1
assert corps["erreurs"] == []
resp = _distribuer(client, corps["jeton"])
assert resp.status_code == 200
assert resp.json()["valeur"] == VALEUR_TEST
liste = client.get("/api/coffres/consommateurs").json()
assert corps["jeton"] not in json.dumps(liste)
assert "jeton_hash" not in json.dumps(liste)
def test_avec_octrois_erreur_signalee_sans_annuler(self, client):
_creer_coffre_secret(client)
resp = client.post(
"/api/coffres/consommateurs/avec-octrois",
json={
"nom": "flowdeck",
"octrois": [
{"coffre_slug": "personnel", "secret_nom": "gitea-token"},
{"coffre_slug": "inexistant", "secret_nom": "gitea-token"},
],
},
)
assert resp.status_code == 200, resp.text
assert len(resp.json()["octrois"]) == 1
assert len(resp.json()["erreurs"]) == 1
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"