"""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 = ""