307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""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
|