Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c46d6dc519 | ||
|
|
4184ba0272 | ||
|
|
751e2f4f44 | ||
|
|
975aff33a5 | ||
|
|
003e44f55e |
@@ -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 = ""
|
||||||
+57
-1
@@ -52,6 +52,7 @@ from app import forecast as forecast_service
|
|||||||
from app import rules as rules_service
|
from app import rules as rules_service
|
||||||
from app import sprint as sprint_service
|
from app import sprint as sprint_service
|
||||||
from app import connections as connections_service
|
from app import connections as connections_service
|
||||||
|
from app import coffre_client
|
||||||
from app import board_columns as bcols
|
from app import board_columns as bcols
|
||||||
from app.briefing import (
|
from app.briefing import (
|
||||||
format_briefing_text,
|
format_briefing_text,
|
||||||
@@ -1149,6 +1150,12 @@ async def admin_create_connection(
|
|||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
"/admin/connections?err=Nom+et+URL+requis", status_code=303
|
"/admin/connections?err=Nom+et+URL+requis", status_code=303
|
||||||
)
|
)
|
||||||
|
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||||
|
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
slug = _unique_slug(_slugify(name), "connections")
|
slug = _unique_slug(_slugify(name), "connections")
|
||||||
conn_id = models.create_connection(
|
conn_id = models.create_connection(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
@@ -1193,6 +1200,12 @@ async def admin_update_connection(
|
|||||||
if token:
|
if token:
|
||||||
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
|
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
|
||||||
if hook_secret:
|
if hook_secret:
|
||||||
|
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||||
|
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
fields["webhook_secret"] = hook_secret
|
fields["webhook_secret"] = hook_secret
|
||||||
if fields:
|
if fields:
|
||||||
models.update_connection(conn_id, **fields)
|
models.update_connection(conn_id, **fields)
|
||||||
@@ -4879,7 +4892,50 @@ async def _handle_forge_webhook(request: Request, conn_row):
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
raw = await request.body()
|
raw = await request.body()
|
||||||
kind = conn_row["kind"] if conn_row else "gitea"
|
kind = conn_row["kind"] if conn_row else "gitea"
|
||||||
secret = (conn_row or {}).get("webhook_secret") or settings.webhook_secret
|
# Secret webhook : une référence coffre:<slug>/<nom> stockée dans
|
||||||
|
# forge_connections.webhook_secret est distribuée à chaque webhook
|
||||||
|
# (Phase 4, issue ObsiGate #22), utilisée en mémoire pour ce seul
|
||||||
|
# HMAC, puis oubliée. Aucun repli : si la distribution échoue
|
||||||
|
# (octroi révoqué, ObsiGate indisponible ou consommateur non
|
||||||
|
# configuré), le webhook échoue proprement avec un message explicite
|
||||||
|
# orientant vers ObsiGate, sans retomber sur le secret d'environnement
|
||||||
|
# ni sur une ancienne valeur (il n'y en a pas de stockée).
|
||||||
|
# Les valeurs historiques en clair et le repli environnement restent
|
||||||
|
# inchangés (compatibilité) tant qu'aucune référence n'est saisie.
|
||||||
|
stocke = (conn_row or {}).get("webhook_secret") or ""
|
||||||
|
if coffre_client.est_reference(stocke):
|
||||||
|
try:
|
||||||
|
secret = await coffre_client.distribuer_async(
|
||||||
|
stocke,
|
||||||
|
contexte_type="webhook",
|
||||||
|
contexte_id=(conn_row or {}).get("slug") or "gitea",
|
||||||
|
)
|
||||||
|
except coffre_client.CoffreAccesRefuse as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "Accès révoqué ou refusé dans ObsiGate : "
|
||||||
|
"ré-accorder l'octroi du secret webhook pour le "
|
||||||
|
"consommateur Kanbante dans ObsiGate, puis renvoyer "
|
||||||
|
"le webhook.",
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except coffre_client.CoffreErreur as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "ObsiGate indisponible ou consommateur Kanbante "
|
||||||
|
"non configuré : impossible de vérifier ce webhook "
|
||||||
|
"sans distribuer son secret. Vérifier ObsiGate, puis "
|
||||||
|
"renvoyer le webhook.",
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
secret = stocke or settings.webhook_secret
|
||||||
if secret:
|
if secret:
|
||||||
if kind == "github":
|
if kind == "github":
|
||||||
signature = request.headers.get("x-hub-signature-256", "")
|
signature = request.headers.get("x-hub-signature-256", "")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
||||||
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
||||||
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
||||||
<label>Secret webhook (vide = inchangé) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
<label>Secret webhook (vide = inchangé, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="admin-actions">
|
<p class="admin-actions">
|
||||||
@@ -68,10 +68,10 @@
|
|||||||
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
||||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
||||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||||
<label>Secret webhook (optionnel) <input type="password" name="hook_secret" autocomplete="off"></label>
|
<label>Secret webhook (optionnel, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="coffre:<slug>/<nom>"></label>
|
||||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="notes-hint">Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
<p class="notes-hint">Secret webhook : une référence <code>coffre:<slug>/<nom></code> est stockée telle quelle (jamais la valeur) et distribuée par ObsiGate à chaque webhook, puis oubliée ; octroi révoqué ou ObsiGate indisponible = webhook rejeté, sans repli. Les jetons d'accès forge, eux, restent chiffrés au repos (voir app/forge_crypto.py) et ne basculent pas encore sur une référence — voir docs/coffre-reference-obsigate.md. Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Kanbante — Références coffre ObsiGate (Phase 4, issue ObsiGate #22)
|
||||||
|
|
||||||
|
Patron « même patron, sans variante » de la spec « Coffre de secrets
|
||||||
|
ObsiGate » §12, appliqué à Kanbante sur la branche
|
||||||
|
`feat/coffre-reference-obsigate` (aucun merge, aucune PR dans ce lot).
|
||||||
|
|
||||||
|
## 1. Inventaire honnête des secrets externes (vérifié dans le code, main 0.37.1)
|
||||||
|
|
||||||
|
| Secret | Stockage actuel | Statut dans ce lot |
|
||||||
|
|---|---|---|
|
||||||
|
| Jeton d'accès forge (`forge_connections.token_encrypted`, admin + profil) | **Chiffré Fernet au repos** (`app/forge_crypto.py`, clé `KANBANTE_MASTER_KEY` ou clé générée en base) ; jamais réaffiché (masque `••••••••`) | **Non migré, à raison** : ce n'est pas un secret en clair. Le basculer sur une référence exigerait une distribution *à chaque appel* forge, car le registre met en cache les clients (`ForgeRegistry`) : distribuer une fois à la construction puis garder le client reviendrait à conserver la valeur en mémoire pour la durée de vie de l'app, ce qui violerait le « utilise, puis oublie » de la spec. Reste à faire dédié (voir §5). |
|
||||||
|
| Secret webhook (`forge_connections.webhook_secret`, admin) | **En clair en base** (colonne `TEXT`, saisie via `/admin/connections`) ; repli environnement `KANBANTE_WEBHOOK_SECRET` | **Migré** : une référence `coffre:<slug>/<nom>` saisie dans ce même champ est stockée telle quelle, distribuée à chaque webhook, puis oubliée (§2). Les valeurs historiques en clair restent acceptées (compatibilité, migration opportuniste), sans jamais être réécrites. |
|
||||||
|
| `GITEA_TOKEN`, `KANBANTE_NTFY_TOKEN`, `KANBANTE_OBSIGATE_MCP_TOKEN`, `KANBANTE_CAPTURE_TOKEN`, `AGENT_EVENEMENTS_CLE`, secrets SSO/session | Variables d'environnement d'exploitation uniquement, jamais persistées par l'app, non surchargeables en base (`OVERRIDABLE_SETTINGS` les exclut explicitement) | **Hors périmètre applicatif** : leur bascule éventuelle vers le coffre relève du déploiement (l'opérateur), pas d'un champ de saisie de Kanbante. Le jeton MCP ObsiGate (`KANBANTE_OBSIGATE_MCP_TOKEN`) n'est **pas** réutilisé ici : le consommateur coffre a son propre jeton, conformément à l'issue. |
|
||||||
|
| Mots de passe utilisateurs, jetons API Kanbante | Hashés (`password_hash`, `token_hash`) | Hors périmètre (pas des secrets externes). |
|
||||||
|
|
||||||
|
Conclusion : Kanbante ne stockait qu'**un** secret externe en clair
|
||||||
|
persisté par l'app — le secret webhook — et c'est celui qui est migré.
|
||||||
|
Le client générique ci-dessous reste disponible pour le premier usage
|
||||||
|
réel suivant (jetons forge, voir §5).
|
||||||
|
|
||||||
|
## 2. Ce qui est implémenté
|
||||||
|
|
||||||
|
### `app/coffre_client.py` (nouveau, dépendance : `httpx` uniquement)
|
||||||
|
|
||||||
|
- `est_reference()` / `parse_reference()` : validation du format
|
||||||
|
`coffre:<slug>/<nom>` (préfixe, un seul `/`, slug et nom non vides).
|
||||||
|
- `config_depuis_env()` : `KANBANTE_OBSIGATE_URL` (base ObsiGate, ex.
|
||||||
|
`https://og.dracodev.net`), `KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`
|
||||||
|
(jeton du consommateur « kanbante » enregistré dans ObsiGate),
|
||||||
|
`KANBANTE_COFFRE_TIMEOUT` (défaut 10 s). Absence de l'un des deux
|
||||||
|
premiers → `CoffreNonConfigure`, échec propre.
|
||||||
|
- `distribuer()` / `distribuer_async()` : `POST /api/coffres/distribuer`
|
||||||
|
avec JSON `{reference, contexte_type, contexte_id}` et
|
||||||
|
`Authorization: Bearer <jeton consommateur>` — contrat vérifié dans le
|
||||||
|
routeur ObsiGate (`backend/routers/coffres.py`, main). La valeur
|
||||||
|
retournée n'est **jamais journalisée, jamais persistée, jamais mise
|
||||||
|
en cache**.
|
||||||
|
- `usage_distribue()` / `usage_distribue_async()` : gestionnaires de
|
||||||
|
contexte « un seul usage » — la valeur ne doit pas sortir du bloc.
|
||||||
|
- Erreurs typées, sans repli, avec messages explicites orientant vers
|
||||||
|
ObsiGate (voir §3). Aucune valeur distribuée n'apparaît dans un
|
||||||
|
message, un log ou une réponse HTTP.
|
||||||
|
|
||||||
|
### Webhook (`app/main.py`, `_handle_forge_webhook`)
|
||||||
|
|
||||||
|
- Si `webhook_secret` stocké est une référence : distribution
|
||||||
|
asynchrone à chaque webhook (`contexte_type="webhook"`,
|
||||||
|
`contexte_id=<slug de la connexion>`), HMAC vérifié avec la valeur en
|
||||||
|
mémoire, puis oubli (fin de portée de la fonction).
|
||||||
|
- Échec de distribution → **aucun repli** sur le secret
|
||||||
|
d'environnement ni sur une ancienne valeur (il n'y en a pas de
|
||||||
|
stockée) : `CoffreAccesRefuse` → HTTP 401 « Accès révoqué ou refusé
|
||||||
|
dans ObsiGate : ré-accorder l'octroi… » ; autre `CoffreErreur`
|
||||||
|
(ObsiGate indisponible, consommateur non configuré) → HTTP 503
|
||||||
|
explicite. Le webhook doit être renvoyé après correction côté
|
||||||
|
ObsiGate.
|
||||||
|
- Si la valeur stockée n'est pas une référence : comportement historique
|
||||||
|
inchangé (secret en clair, puis repli environnement).
|
||||||
|
|
||||||
|
### Formulaire admin (`app/main.py` + `app/templates/admin_connections.html`)
|
||||||
|
|
||||||
|
- Le champ « Secret webhook » accepte désormais une référence
|
||||||
|
`coffre:<slug>/<nom>` (création et modification). Une saisie
|
||||||
|
commençant par `coffre:` mais mal formée est **refusée** (redirection
|
||||||
|
d'erreur), pour ne jamais la stocker comme secret en clair historique
|
||||||
|
par accident.
|
||||||
|
- Le champ « Jeton d'accès » est **inchangé** : une référence saisie
|
||||||
|
là serait aujourd'hui chiffrée Fernet puis rejetée par la forge —
|
||||||
|
c'est volontaire tant que la distribution par appel (§5) n'existe pas.
|
||||||
|
Le bandeau du formulaire le signale.
|
||||||
|
|
||||||
|
Aucun changement de schéma n'a été nécessaire : la référence est
|
||||||
|
stockée dans la colonne `webhook_secret` existante, détectée par son
|
||||||
|
préfixe `coffre:` (un secret historique ne peut pas porter ce préfixe
|
||||||
|
sans avoir été saisi comme tel, et ce cas est désormais validé).
|
||||||
|
|
||||||
|
## 3. Correspondance des erreurs ObsiGate
|
||||||
|
|
||||||
|
| Réponse ObsiGate | Erreur Kanbante | Effet |
|
||||||
|
|---|---|---|
|
||||||
|
| 401 `consommateur_invalide`, 403 `consommateur_revoque` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder » |
|
||||||
|
| 403 `octroi_absent` / `octroi_revoque` / `octroi_expire`, 410 `secret_expire` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder / renouveler » |
|
||||||
|
| 400 `reference_invalide` / `contexte_manquant` | `CoffreReferenceInvalide` / `CoffreContexteManquant` | Webhook 503 (erreur de configuration) |
|
||||||
|
| 503 `kek_absente` / `crypto_indisponible`, timeout, connexion refusée, 200 sans valeur | `CoffreIndisponible` | Webhook 503, renvoyer le webhook plus tard |
|
||||||
|
|
||||||
|
Dans tous les cas : pas de valeur en cache, pas de continuation.
|
||||||
|
|
||||||
|
## 4. Tests
|
||||||
|
|
||||||
|
`tests/test_coffre_client.py` (faux endpoint `httpx.MockTransport`,
|
||||||
|
valeurs fictives `TEST-VALUE-XXXX` uniquement, aucune instance réelle) :
|
||||||
|
|
||||||
|
- validation de références (valides / invalides) ;
|
||||||
|
- succès sync + async, avec vérification de la requête (chemin, Bearer,
|
||||||
|
corps `reference` + contexte) ;
|
||||||
|
- refus (octroi révoqué/expiré/absent, consommateur révoqué/invalide,
|
||||||
|
secret expiré) → `CoffreAccesRefuse` avec message explicite ;
|
||||||
|
- indisponibilité (503, erreur réseau, 200 sans valeur) →
|
||||||
|
`CoffreIndisponible` ;
|
||||||
|
- référence invalide / contexte manquant / configuration absente :
|
||||||
|
aucun appel HTTP ;
|
||||||
|
- webhook de bout en bout (TestClient) : octroi révoqué → 401 explicite
|
||||||
|
sans repli ; distribution réussie → HMAC accepté et ligne en base
|
||||||
|
toujours porteuse de la référence, jamais de la valeur.
|
||||||
|
|
||||||
|
## 5. Restes à faire (exigent validation par Bruno sur instance réelle)
|
||||||
|
|
||||||
|
1. **Validation réelle** : enregistrer le consommateur « kanbante »
|
||||||
|
dans ObsiGate, créer un octroi vers un secret webhook de test,
|
||||||
|
configurer `KANBANTE_OBSIGATE_URL` +
|
||||||
|
`KANBANTE_COFFRE_CONSOMMATEUR_TOKEN` sur l'instance Kanbante, saisir
|
||||||
|
la référence dans `/admin/connections`, envoyer un vrai webhook
|
||||||
|
Gitea, vérifier l'acceptation, puis révoquer l'octroi et vérifier
|
||||||
|
le 401 explicite, puis couper ObsiGate et vérifier le 503.
|
||||||
|
2. **Jetons forge par référence** : distribution à chaque appel dans
|
||||||
|
`GiteaClient`/`GitHubClient` (ou client jetable par requête), sans
|
||||||
|
passer par le cache `ForgeRegistry` ; seulement alors, accepter une
|
||||||
|
référence dans le champ « Jeton d'accès » et documenter la migration
|
||||||
|
des connexions existantes (chiffrées Fernet → références).
|
||||||
|
3. **Migration opportuniste des secrets webhook historiques** : proposer
|
||||||
|
en admin la conversion d'un secret en clair existant vers une
|
||||||
|
référence (l'admin crée le secret + l'octroi dans ObsiGate, puis
|
||||||
|
remplace la valeur par la référence) — aucun outil automatique ne
|
||||||
|
copie une valeur réelle vers ObsiGate dans ce lot.
|
||||||
|
4. **CI complète** : la suite n'a été exécutée que localement dans le
|
||||||
|
sandbox (voir rapport) ; le run CI Gitea de la branche reste à
|
||||||
|
observer avant toute PR.
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Tests du client de distribution coffre ObsiGate (Phase 4, issue ObsiGate #22).
|
||||||
|
|
||||||
|
Aucune instance ObsiGate réelle n'est contactée : le « faux endpoint » est
|
||||||
|
un ``httpx.MockTransport`` local, et toutes les valeurs distribuées dans
|
||||||
|
ces tests sont fictives (``TEST-VALUE-XXXX``), jamais des secrets réels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app import models
|
||||||
|
from app.coffre_client import (
|
||||||
|
CoffreAccesRefuse,
|
||||||
|
CoffreConfig,
|
||||||
|
CoffreContexteManquant,
|
||||||
|
CoffreIndisponible,
|
||||||
|
CoffreNonConfigure,
|
||||||
|
CoffreReferenceInvalide,
|
||||||
|
distribuer,
|
||||||
|
distribuer_async,
|
||||||
|
est_reference,
|
||||||
|
parse_reference,
|
||||||
|
usage_distribue,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuration fictive, réservée aux tests.
|
||||||
|
CONFIG_TEST = CoffreConfig(
|
||||||
|
base_url="https://obsigate.test",
|
||||||
|
jeton_consommateur="TEST-CONSUMER-TOKEN",
|
||||||
|
)
|
||||||
|
REFERENCE_TEST = "coffre:TEST-SLUG/TEST-NOM"
|
||||||
|
VALEUR_TEST = "TEST-VALUE-XXXX"
|
||||||
|
|
||||||
|
|
||||||
|
def _transport_faux(statut=200, corps=None, verifier=None):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if verifier is not None:
|
||||||
|
verifier(request)
|
||||||
|
return httpx.Response(statut, json=corps if corps is not None else {})
|
||||||
|
|
||||||
|
return httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _verifier_requete(request: httpx.Request):
|
||||||
|
assert request.url.path == "/api/coffres/distribuer"
|
||||||
|
assert request.headers["Authorization"] == "Bearer TEST-CONSUMER-TOKEN"
|
||||||
|
corps = json.loads(request.content.decode())
|
||||||
|
assert corps["reference"] == REFERENCE_TEST
|
||||||
|
assert corps["contexte_type"] == "webhook"
|
||||||
|
assert corps["contexte_id"] == "gitea"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reference, attendu",
|
||||||
|
[
|
||||||
|
("coffre:personnel/gitea", True),
|
||||||
|
("coffre:TEST-SLUG/TEST-NOM", True),
|
||||||
|
("coffre:a/b/c", False), # un seul « / » après le préfixe
|
||||||
|
("coffre:/nom", False),
|
||||||
|
("coffre:slug/", False),
|
||||||
|
("coffre:slug", False),
|
||||||
|
("gitea", False),
|
||||||
|
("", False),
|
||||||
|
(None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_reference_validation(reference, attendu):
|
||||||
|
assert est_reference(reference) is attendu
|
||||||
|
if attendu:
|
||||||
|
assert parse_reference(reference)[0]
|
||||||
|
else:
|
||||||
|
with pytest.raises(CoffreReferenceInvalide):
|
||||||
|
parse_reference(reference or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_succes_sync():
|
||||||
|
transport = _transport_faux(
|
||||||
|
200,
|
||||||
|
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||||
|
verifier=_verifier_requete,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
== VALEUR_TEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_distribuer_succes_async():
|
||||||
|
transport = _transport_faux(
|
||||||
|
200,
|
||||||
|
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||||
|
verifier=_verifier_requete,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await distribuer_async(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport
|
||||||
|
)
|
||||||
|
== VALEUR_TEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"statut, detail, message_attendu",
|
||||||
|
[
|
||||||
|
(403, "octroi_revoque", "ré-accorder"),
|
||||||
|
(403, "octroi_expire", "renouveler"),
|
||||||
|
(403, "octroi_absent", "octroi"),
|
||||||
|
(401, "consommateur_revoque", "ré-accorder"),
|
||||||
|
(401, "consommateur_invalide", "invalide"),
|
||||||
|
(410, "secret_expire", "expiré"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_distribuer_refus_sans_repli(statut, detail, message_attendu):
|
||||||
|
transport = _transport_faux(statut, {"detail": detail})
|
||||||
|
with pytest.raises(CoffreAccesRefuse) as excinfo:
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
assert message_attendu in str(excinfo.value)
|
||||||
|
assert VALEUR_TEST not in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_indisponible_503_et_reseau():
|
||||||
|
transport = _transport_faux(503, {"detail": "kek_absente"})
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
|
||||||
|
def _panne(request: httpx.Request):
|
||||||
|
raise httpx.ConnectError("faux ObsiGate injoignable", request=request)
|
||||||
|
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=httpx.MockTransport(_panne),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_sans_valeur_malgre_200():
|
||||||
|
transport = _transport_faux(200, {"reference": REFERENCE_TEST})
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_reference_invalide_sans_appel():
|
||||||
|
def _interdit(request: httpx.Request):
|
||||||
|
raise AssertionError("aucun appel HTTP ne doit partir")
|
||||||
|
|
||||||
|
with pytest.raises(CoffreReferenceInvalide):
|
||||||
|
distribuer("valeur-brute", "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=httpx.MockTransport(_interdit))
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_contexte_manquant_sans_appel():
|
||||||
|
with pytest.raises(CoffreContexteManquant):
|
||||||
|
distribuer(REFERENCE_TEST, "", "gitea", config=CONFIG_TEST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_non_configure(monkeypatch):
|
||||||
|
monkeypatch.delenv("KANBANTE_OBSIGATE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", raising=False)
|
||||||
|
with pytest.raises(CoffreNonConfigure):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea")
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_distribue_ne_conserve_que_le_resultat():
|
||||||
|
transport = _transport_faux(200, {"valeur": VALEUR_TEST})
|
||||||
|
# Le bloc ne sert qu'à l'usage : ici, dériver un HMAC, comme le ferait
|
||||||
|
# la vérification d'un webhook. Seul le résultat de l'usage est
|
||||||
|
# conservé après le bloc, jamais la valeur distribuée.
|
||||||
|
with usage_distribue(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=transport,
|
||||||
|
) as valeur:
|
||||||
|
signature = hmac.new(valeur.encode(), b"corps-test", hashlib.sha256).hexdigest()
|
||||||
|
assert len(signature) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_reference_octroi_revoque_sans_repli(client, monkeypatch):
|
||||||
|
"""Référence webhook + octroi révoqué -> 401 explicite, sans repli."""
|
||||||
|
models.create_connection(
|
||||||
|
"coffre-webhook", "gitea", "Coffre webhook", "https://gitea.test",
|
||||||
|
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _refuse(*args, **kwargs):
|
||||||
|
raise CoffreAccesRefuse("Accès révoqué dans ObsiGate pour cette référence.")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.coffre_client, "distribuer_async", _refuse)
|
||||||
|
reponse = client.post(
|
||||||
|
"/webhooks/coffre-webhook",
|
||||||
|
content=b"{}",
|
||||||
|
headers={"x-gitea-signature": "0" * 64},
|
||||||
|
)
|
||||||
|
assert reponse.status_code == 401
|
||||||
|
assert "ObsiGate" in reponse.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_reference_distribuee_puis_oubliee(client, monkeypatch):
|
||||||
|
"""Référence webhook distribuée -> HMAC vérifié, valeur non persistée."""
|
||||||
|
models.create_connection(
|
||||||
|
"coffre-webhook-ok", "gitea", "Coffre webhook OK", "https://gitea.test",
|
||||||
|
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _distribue(reference, contexte_type, contexte_id):
|
||||||
|
assert reference == REFERENCE_TEST
|
||||||
|
assert contexte_type == "webhook"
|
||||||
|
assert contexte_id == "coffre-webhook-ok"
|
||||||
|
return VALEUR_TEST
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.coffre_client, "distribuer_async", _distribue)
|
||||||
|
corps = b"{}"
|
||||||
|
signature = hmac.new(VALEUR_TEST.encode(), corps, hashlib.sha256).hexdigest()
|
||||||
|
reponse = client.post(
|
||||||
|
"/webhooks/coffre-webhook-ok",
|
||||||
|
content=corps,
|
||||||
|
headers={"x-gitea-signature": signature},
|
||||||
|
)
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
# La valeur distribuée n'a jamais été persistée : la ligne stocke
|
||||||
|
# toujours la référence, jamais la valeur fictive.
|
||||||
|
ligne = models.get_connection_by_slug("coffre-webhook-ok")
|
||||||
|
assert ligne["webhook_secret"] == REFERENCE_TEST
|
||||||
|
assert VALEUR_TEST not in (ligne["webhook_secret"] or "")
|
||||||
Reference in New Issue
Block a user