[Coffre Secrets] Phase 2 — Migration api_keys.json, sauvegarde/restauration KEK, contrat distribution, consommateurs + correctif UI modale #29
@@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat
|
||||
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
||||
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
||||
# 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.
|
||||
|
||||
@@ -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
@@ -19,6 +19,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
|
||||
utilisé ici, volontairement).
|
||||
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
||||
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
|
||||
@@ -26,15 +34,22 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
|
||||
from backend.audit import get_recent_entries, log_coffre_event
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.coffre_crypto import (
|
||||
CryptoIndisponibleError,
|
||||
KEKAbsenteError,
|
||||
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 (
|
||||
CoffreExisteError,
|
||||
IntrouvableError,
|
||||
@@ -273,6 +288,43 @@ async def api_create_consommateur(
|
||||
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}")
|
||||
async def api_revoke_consommateur(
|
||||
consommateur_id: str, current_user=Depends(require_admin)
|
||||
@@ -291,6 +343,67 @@ async def api_revoke_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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -299,8 +412,20 @@ async def api_revoke_consommateur(
|
||||
async def api_distribuer(request: Request, body: dict = Body(...)):
|
||||
_exiger_actives()
|
||||
reference = (body.get("reference") or "").strip()
|
||||
contexte_type = (body.get("contexte_type") or "").strip()
|
||||
contexte_id = (body.get("contexte_id") or "").strip()
|
||||
# Phase 3 (Spark) : le contexte peut arriver à plat
|
||||
# (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:
|
||||
raise HTTPException(status_code=400, detail="contexte_manquant")
|
||||
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -18,6 +18,11 @@
|
||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
||||
* 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
|
||||
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
|
||||
* 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) {
|
||||
if (!isAdmin()) return;
|
||||
if (mode === 'generer') {
|
||||
@@ -1002,6 +1084,12 @@ export async function openCoffresView(slug, options = {}) {
|
||||
});
|
||||
toolbar.appendChild(search);
|
||||
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 () => {
|
||||
const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true });
|
||||
if (!ok) return;
|
||||
|
||||
@@ -2757,5 +2757,16 @@
|
||||
"coffres.type": "Type",
|
||||
"coffres.value": "Value",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -2757,5 +2757,16 @@
|
||||
"coffres.type": "Type",
|
||||
"coffres.value": "Valeur",
|
||||
"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
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease;
|
||||
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
|
||||
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);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.obsigate-modal-title {
|
||||
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
|
||||
|
||||
.obsigate-modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.obsigate-modal-footer {
|
||||
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -455,3 +456,225 @@ class TestDistribution:
|
||||
assert "secret_cree" in actions
|
||||
assert "distribution_ok" in actions
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user