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.