diff --git a/app/coffre_client.py b/app/coffre_client.py new file mode 100644 index 0000000..787797a --- /dev/null +++ b/app/coffre_client.py @@ -0,0 +1,341 @@ +"""Client de distribution du coffre de secrets ObsiGate (Phase 4, issue ObsiGate #22). + +Patron imposé par la spec « Coffre de secrets ObsiGate » §12, appliqué ici +sans variante : + +- Kanbante ne stocke QUE des références ``coffre:/``, jamais une + valeur distribuée ; +- à chaque usage, le SERVEUR Kanbante appelle + ``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur + Kanbante (distinct du jeton MCP de notes et de la session SSO) et un + contexte obligatoire, utilise la valeur en mémoire, puis l'oublie ; +- aucun cache de valeur, aucune persistance, aucun log de la valeur ; +- octroi révoqué -> échec explicite orientant vers ObsiGate, sans repli + sur une ancienne valeur ; +- ObsiGate indisponible -> échec propre, sans continuer avec une valeur + en cache (il ne doit pas y en avoir). + +Configuration (variables d'environnement, jamais versionnées) : +- ``KANBANTE_OBSIGATE_URL`` : base d'ObsiGate, ex. ``https://og.dracodev.net`` +- ``KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`` : jeton du consommateur + « kanbante » enregistré dans ObsiGate (jeton révocable, hash + jti + côté ObsiGate ; il n'est jamais renvoyé au client ni journalisé). +- ``KANBANTE_COFFRE_TIMEOUT`` : délai en secondes (défaut 10). + +Ce module ne dépend que de ``httpx`` (déjà dépendance du projet) et de +la bibliothèque standard, pour rester testable avec un faux endpoint +(``httpx.MockTransport``) sans instance ObsiGate réelle. +""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger("kanbante.coffre") + +PREFIXE_REFERENCE = "coffre:" +CHEMIN_DISTRIBUER = "/api/coffres/distribuer" +TIMEOUT_DEFAUT = 10.0 + +# Messages explicites, orientant vers ObsiGate. Ils ne contiennent jamais +# la valeur distribuée, ni le jeton consommateur. +_MESSAGES_DETAIL = { + "consommateur_invalide": ( + "Consommateur Kanbante invalide dans ObsiGate : " + "vérifier le jeton consommateur Kanbante dans ObsiGate." + ), + "consommateur_revoque": ( + "Consommateur Kanbante révoqué dans ObsiGate : " + "ré-accorder l'accès du consommateur Kanbante dans ObsiGate." + ), + "octroi_absent": ( + "Aucun octroi actif pour cette référence dans ObsiGate : " + "créer l'octroi pour le consommateur Kanbante dans ObsiGate." + ), + "octroi_revoque": ( + "Accès révoqué dans ObsiGate pour cette référence : " + "ré-accorder l'octroi dans ObsiGate, puis réessayer." + ), + "octroi_expire": ( + "Octroi expiré dans ObsiGate pour cette référence : " + "renouveler l'octroi dans ObsiGate, puis réessayer." + ), + "secret_expire": ( + "Secret expiré dans ObsiGate : " + "renouveler le secret dans ObsiGate, puis réessayer." + ), + "reference_invalide": ( + "Référence invalide (format attendu coffre:/)." + ), + "contexte_manquant": ( + "Contexte de distribution manquant : " + "un contexte (type + identifiant) est obligatoire côté ObsiGate." + ), + "kek_absente": "ObsiGate indisponible : clé de chiffrement absente côté ObsiGate.", + "crypto_indisponible": "ObsiGate indisponible : chiffrement indisponible côté ObsiGate.", +} + + +class CoffreErreur(Exception): + """Erreur de distribution, sans jamais exposer une valeur secrète.""" + + +class CoffreReferenceInvalide(CoffreErreur): + """La référence ne respecte pas le format ``coffre:/``.""" + + +class CoffreContexteManquant(CoffreErreur): + """Le contexte de distribution (type + identifiant) est obligatoire.""" + + +class CoffreNonConfigure(CoffreErreur): + """ObsiGate ou le jeton consommateur Kanbante n'est pas configuré.""" + + +class CoffreAccesRefuse(CoffreErreur): + """ObsiGate a refusé la distribution (octroi/consommateur/secret). + + Aucun repli sur une ancienne valeur n'est autorisé : l'appelant doit + échouer proprement avec ce message. + """ + + +class CoffreIndisponible(CoffreErreur): + """ObsiGate est injoignable ou a répondu sans valeur utilisable.""" + + +@dataclass(frozen=True) +class CoffreConfig: + """Configuration du consommateur Kanbante côté ObsiGate.""" + + base_url: str + jeton_consommateur: str + timeout: float = TIMEOUT_DEFAUT + + @property + def url_distribuer(self) -> str: + return self.base_url.rstrip("/") + CHEMIN_DISTRIBUER + + +def config_depuis_env() -> CoffreConfig: + """Construit la configuration depuis l'environnement. + + Lève : CoffreNonConfigure si la base ou le jeton est absent. + Le jeton n'est jamais journalisé, même en cas d'erreur. + """ + base_url = os.getenv("KANBANTE_OBSIGATE_URL", "").strip() + jeton = os.getenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", "").strip() + try: + timeout = float(os.getenv("KANBANTE_COFFRE_TIMEOUT", "") or TIMEOUT_DEFAUT) + except ValueError: + timeout = TIMEOUT_DEFAUT + if not base_url or not jeton: + raise CoffreNonConfigure( + "Consommateur coffre Kanbante non configuré : définir " + "KANBANTE_OBSIGATE_URL et KANBANTE_COFFRE_CONSOMMATEUR_TOKEN." + ) + return CoffreConfig(base_url=base_url, jeton_consommateur=jeton, timeout=timeout) + + +def est_reference(valeur: str | None) -> bool: + """Vrai si ``valeur`` est une référence coffre (et non une valeur brute).""" + if not valeur: + return False + try: + parse_reference(valeur) + except CoffreReferenceInvalide: + return False + return True + + +def parse_reference(reference: str) -> tuple[str, str]: + """Valide ``coffre:/`` et retourne ``(slug, nom)``. + + Lève : CoffreReferenceInvalide si le format est incorrect. + La validation reste volontairement permissive sur les caractères + (ObsiGate reste l'autorité de validation) : préfixe, un « / », + slug et nom non vides, aucun espace. + """ + reference = (reference or "").strip() + if not reference.startswith(PREFIXE_REFERENCE): + raise CoffreReferenceInvalide( + "Référence invalide (format attendu coffre:/)." + ) + reste = reference[len(PREFIXE_REFERENCE):] + if "/" not in reste or any(c.isspace() for c in reference): + raise CoffreReferenceInvalide( + "Référence invalide (format attendu coffre:/)." + ) + slug, nom = reste.split("/", 1) + if not slug or not nom or "/" in nom: + raise CoffreReferenceInvalide( + "Référence invalide (format attendu coffre:/)." + ) + return slug, nom + + +def _valider_contexte(contexte_type: str, contexte_id: str) -> None: + if not (contexte_type or "").strip() or not (contexte_id or "").strip(): + raise CoffreContexteManquant( + "Contexte de distribution manquant : un contexte " + "(type + identifiant) est obligatoire." + ) + + +def _message_erreur(statut: int, detail: str) -> CoffreErreur: + """Mappe une réponse d'erreur ObsiGate vers une erreur typée. + + Les statuts 401/403/410 signalent un refus d'accès (octroi, + consommateur ou secret) : jamais de repli. Les autres erreurs + (400 de format, 503, réponse sans valeur) sont des indisponibilités + ou des erreurs de configuration, également sans repli. + """ + message = _MESSAGES_DETAIL.get(detail) + if statut in (401, 403, 410) or detail in ( + "octroi_absent", + "octroi_revoque", + "octroi_expire", + "consommateur_invalide", + "consommateur_revoque", + "secret_expire", + ): + return CoffreAccesRefuse(message or f"Distribution refusée par ObsiGate ({detail}).") + if detail == "reference_invalide": + return CoffreReferenceInvalide(message or "Référence invalide.") + if detail == "contexte_manquant": + return CoffreContexteManquant(message or "Contexte manquant.") + return CoffreIndisponible( + message or f"ObsiGate indisponible (statut {statut})." + ) + + +def _traiter_reponse(reponse: httpx.Response, reference: str) -> str: + """Extrait la valeur d'une réponse de distribution, ou lève. + + La valeur extraite n'est jamais journalisée ; seuls la référence, + le contexte et le statut le sont. + """ + if reponse.status_code == 200: + try: + valeur = reponse.json().get("valeur") + except ValueError: + valeur = None + if isinstance(valeur, str) and valeur: + logger.info("distribution coffre ok (reference=%s)", reference) + return valeur + raise CoffreIndisponible( + "ObsiGate a répondu sans valeur distribuable." + ) + try: + detail = str(reponse.json().get("detail") or "") + except ValueError: + detail = "" + logger.warning( + "distribution coffre refusee (reference=%s, statut=%d, detail=%s)", + reference, + reponse.status_code, + detail, + ) + raise _message_erreur(reponse.status_code, detail) + + +def _corps_requete(reference: str, contexte_type: str, contexte_id: str) -> dict: + return { + "reference": reference, + "contexte_type": contexte_type, + "contexte_id": contexte_id, + } + + +def distribuer( + reference: str, + contexte_type: str, + contexte_id: str, + config: CoffreConfig | None = None, + transport: httpx.BaseTransport | None = None, +) -> str: + """Distribue une valeur ObsiGate, en mémoire seulement. + + L'appelant doit utiliser la valeur immédiatement (appel sortant, + vérification HMAC) puis la laisser sortir de portée : ce module ne + la persiste ni ne la met en cache, et ne la journalise jamais. + """ + parse_reference(reference) + _valider_contexte(contexte_type, contexte_id) + cfg = config or config_depuis_env() + try: + with httpx.Client(timeout=cfg.timeout, transport=transport) as client: + reponse = client.post( + cfg.url_distribuer, + json=_corps_requete(reference, contexte_type, contexte_id), + headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"}, + ) + except httpx.HTTPError as exc: + raise CoffreIndisponible( + "ObsiGate injoignable pour la distribution : " + "vérifier qu'ObsiGate est disponible, puis réessayer." + ) from exc + return _traiter_reponse(reponse, reference) + + +async def distribuer_async( + reference: str, + contexte_type: str, + contexte_id: str, + config: CoffreConfig | None = None, + transport: httpx.AsyncBaseTransport | None = None, +) -> str: + """Variante asynchrone de :func:`distribuer` (mêmes garanties).""" + parse_reference(reference) + _valider_contexte(contexte_type, contexte_id) + cfg = config or config_depuis_env() + try: + async with httpx.AsyncClient(timeout=cfg.timeout, transport=transport) as client: + reponse = await client.post( + cfg.url_distribuer, + json=_corps_requete(reference, contexte_type, contexte_id), + headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"}, + ) + except httpx.HTTPError as exc: + raise CoffreIndisponible( + "ObsiGate injoignable pour la distribution : " + "vérifier qu'ObsiGate est disponible, puis réessayer." + ) from exc + return _traiter_reponse(reponse, reference) + + +@contextmanager +def usage_distribue(reference: str, contexte_type: str, contexte_id: str, + config: CoffreConfig | None = None, + transport: httpx.BaseTransport | None = None): + """Distribue une valeur pour un seul usage, dans le bloc ``with``. + + La valeur ne doit pas sortir du bloc (ni être stockée, ni journalisée, + ni retournée) : à la sortie, l'appelant ne conserve que le résultat + de l'usage, jamais la valeur elle-même. + """ + valeur = distribuer(reference, contexte_type, contexte_id, + config=config, transport=transport) + try: + yield valeur + finally: + valeur = "" + + +@asynccontextmanager +async def usage_distribue_async(reference: str, contexte_type: str, + contexte_id: str, + config: CoffreConfig | None = None, + transport: httpx.AsyncBaseTransport | None = None): + """Variante asynchrone de :func:`usage_distribue`.""" + valeur = await distribuer_async(reference, contexte_type, contexte_id, + config=config, transport=transport) + try: + yield valeur + finally: + valeur = ""