diff --git a/app/coffre_client.py b/app/coffre_client.py new file mode 100644 index 0000000..787797a --- /dev/null +++ b/app/coffre_client.py @@ -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:/``, 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:/)." + ), + "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:/``.""" + + +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:/`` 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:/)." + ) + 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 = reste.split("/", 1) + if not slug or not nom or "/" in nom: + raise CoffreReferenceInvalide( + "Référence invalide (format attendu coffre:/)." + ) + 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 = "" diff --git a/app/main.py b/app/main.py index 216e0bf..4764288 100644 --- a/app/main.py +++ b/app/main.py @@ -52,6 +52,7 @@ from app import forecast as forecast_service from app import rules as rules_service from app import sprint as sprint_service from app import connections as connections_service +from app import coffre_client from app import board_columns as bcols from app.briefing import ( format_briefing_text, @@ -1149,6 +1150,12 @@ async def admin_create_connection( return RedirectResponse( "/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") conn_id = models.create_connection( slug=slug, @@ -1193,6 +1200,12 @@ async def admin_update_connection( if token: fields["token_encrypted"] = forge_crypto.encrypt_token(token) 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 if fields: models.update_connection(conn_id, **fields) @@ -4879,7 +4892,50 @@ async def _handle_forge_webhook(request: Request, conn_row): settings = get_settings() raw = await request.body() 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:/ 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 kind == "github": signature = request.headers.get("x-hub-signature-256", "") diff --git a/app/templates/admin_connections.html b/app/templates/admin_connections.html index 2b64539..d5716e5 100644 --- a/app/templates/admin_connections.html +++ b/app/templates/admin_connections.html @@ -35,7 +35,7 @@ - +

@@ -68,10 +68,10 @@ - + -

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.

+

Secret webhook : une référence coffre:<slug>/<nom> 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.

diff --git a/docs/coffre-reference-obsigate.md b/docs/coffre-reference-obsigate.md new file mode 100644 index 0000000..8127301 --- /dev/null +++ b/docs/coffre-reference-obsigate.md @@ -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:/` 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:/` (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 ` — 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=`), 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:/` (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. diff --git a/tests/test_coffre_client.py b/tests/test_coffre_client.py new file mode 100644 index 0000000..be498e1 --- /dev/null +++ b/tests/test_coffre_client.py @@ -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 "")