From 71221e055bf6bc26596a10dd4a96525715742cc5 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:44:58 -0400 Subject: [PATCH] Phase 2 coffre: migration, consommateur+octrois, contexte Spark imbrique --- backend/routers/coffres.py | 131 ++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/backend/routers/coffres.py b/backend/routers/coffres.py index b3aa91c..5375d2b 100644 --- a/backend/routers/coffres.py +++ b/backend/routers/coffres.py @@ -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:]: