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