coffre(obsigate#22): client de distribution generique (reference, contexte, sans cache ni log de valeur)
CI / test (push) Failing after 1s
CI / test (push) Failing after 1s
This commit is contained in:
@@ -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:<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 = ""
|
||||
Reference in New Issue
Block a user