Compare commits

...
Author SHA1 Message Date
bruno c46d6dc519 coffre(obsigate#22): webhook distribue le secret reference a chaque appel, sans repli; validation reference admin
CI / test (push) Failing after 0s
CI / test (pull_request) Failing after 1s
2026-10-27 23:52:37 -04:00
bruno 4184ba0272 coffre(obsigate#22): formulaire admin accepte une reference coffre pour le secret webhook
CI / test (push) Failing after 1s
2026-10-27 23:52:34 -04:00
bruno 751e2f4f44 coffre(obsigate#22): documentation inventaire, migration webhook, limites
CI / test (push) Failing after 1s
2026-10-27 23:52:28 -04:00
bruno 975aff33a5 coffre(obsigate#22): tests client de distribution (faux endpoint, valeurs fictives)
CI / test (push) Failing after 1s
2026-10-27 23:52:25 -04:00
bruno 003e44f55e coffre(obsigate#22): client de distribution generique (reference, contexte, sans cache ni log de valeur)
CI / test (push) Failing after 1s
2026-10-27 23:52:19 -04:00
5 changed files with 755 additions and 4 deletions
+341
View File
@@ -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
View File
@@ -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", "")
+3 -3
View File
@@ -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:&lt;slug&gt;/&lt;nom&gt;) <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:&lt;slug&gt;/&lt;nom&gt;) <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:&lt;slug&gt;/&lt;nom&gt;</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>
+127
View File
@@ -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.
+227
View File
@@ -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 "")