Files
kanbante/docs/coffre-reference-obsigate.md
T
2026-10-27 23:52:28 -04:00

128 lines
8.4 KiB
Markdown

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