# 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.