Files
kanbante/app/coffre_client.py
T

342 lines
12 KiB
Python

"""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:<slug>/<nom>``, 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:<slug>/<nom>)."
),
"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:<slug>/<nom>``."""
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:<slug>/<nom>`` 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:<slug>/<nom>)."
)
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>)."
)
slug, nom = reste.split("/", 1)
if not slug or not nom or "/" in nom:
raise CoffreReferenceInvalide(
"Référence invalide (format attendu coffre:<slug>/<nom>)."
)
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 = ""