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
Spark 53f54b1b79 feat(0.37.1): version du service d'agents visible dans le panneau Spark (mode sidebar) — fusion #64
CI / test (push) Failing after 1s
2026-10-26 16:49:25 +00:00
Spark 80fc260a40 feat(0.37.0): tri dashboard « En attente de revue » + sens des tris numériques corrigé
CI / test (push) Successful in 2m27s
- Nouveau critère 🔍 En attente de revue ↓ (compteur de la
  colonne review exposé en data-review sur les cartes) ;
  s'applique à toutes les grilles, Épinglés en tête,
  préférence mémorisée comme les autres tris.
- Correctif attrapé au contrôle navigateur réel : le tri
  « Issues ouvertes ↓ » historique triait croissant — le
  comparateur inversait les clés numériques. Modes open et
  review en ka - kb : plus grand compteur en premier.
  Vérifié sur données réelles (Épinglés : 196, 95, 32, 5, 4).
- 299 tests verts (3 nouveaux, palier 25).
2026-10-26 11:19:53 -04:00
12 changed files with 882 additions and 6 deletions
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## 0.37.1 — 2026-10-26
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
Carte fusion #64. Le pied du panneau Spark (`static/js/draco-panneau.js`)
affiche désormais la ligne discrète et permanente « Service d'agents
vX », comme la console pleine page. La version est lue **en direct**
dans la santé du service (`GET /healthz` de l'origine que le panneau
connaît déjà), à l'ouverture du panneau, avec cache de session — jamais
une valeur codée en dur ; service injoignable ou panneau non configuré
= rien d'affiché, aucune erreur visible.
## 0.37.0 — 2026-10-26
### Ajouté — tri « En attente de revue » sur le dashboard
Demande de Bruno : pouvoir ordonner les projets — section
**Épinglés** en tête — par ce qui attend sa validation.
- Nouveau critère dans le sélecteur de tri du dashboard,
en première place après « Par défaut » : **🔍 En attente
de revue ↓** — les projets s'ordonnent par le nombre de
cartes dans la colonne de validation (clé `review` de
l'espace), décroissant.
- Le compteur était déjà calculé pour les chips de
progression des cartes : il est maintenant exposé en
`data-review` sur chaque carte projet et consommé par le
moteur de tri partagé — le critère s'applique à **toutes
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
avec ou sans regroupement par propriétaire, et la
préférence est mémorisée comme les autres tris.
- Les critères existants (activité Gitea, activité kanban,
issues ouvertes, nom) sont inchangés.
### Corrigé — sens des tris numériques du dashboard
Le contrôle en navigateur réel a révélé que le tri
« Issues ouvertes ↓ » triait en réalité **croissant**
depuis son introduction : le comparateur partagé inversait
les clés numériques. Les modes numériques (`open` et le
nouveau `review`) trient maintenant en soustraction de
clés — le plus grand compteur en premier, comme l'annonce
le « ↓ ». Vérifié sur les données réelles : Épinglés
ordonnés 196, 95, 32, 5, 4 en attente de revue.
## 0.36.0 — 2026-10-26
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
+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 = ""
+1 -1
View File
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.36.0"
APP_VERSION = "0.37.1"
def _env_bool(name: str, default: bool) -> bool:
+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 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:<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 kind == "github":
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>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>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>
</form>
<p class="admin-actions">
@@ -68,10 +68,10 @@
<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>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>
</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>
</main>
</body>
+2
View File
@@ -64,6 +64,7 @@
<label class="dash-tool">Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="review">🔍 En attente de revue ↓</option>
<option value="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
@@ -85,6 +86,7 @@
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
data-review="{{ stats.get(full, {}).get('review', 0) if stats else 0 }}"
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
+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.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.36.0"
version = "0.37.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+8
View File
@@ -32,6 +32,7 @@
function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
if (mode === 'review') return -(parseInt(card.getAttribute('data-review') || '0', 10));
if (mode === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien
@@ -52,6 +53,13 @@
if (isNaN(tb)) return -1;
return tb - ta;
}
// Modes numériques (open, review) : les clés sont
// des compteurs niés (-n) — ka - kb donne donc le
// plus grand compteur en premier, comme l'annonce
// le « ↓ » des options. (Avant 0.37.0, la
// comparaison générique ci-dessous inversait ces
// deux tris : ils ressortaient croissants.)
if (mode === 'open' || mode === 'review') return ka - kb;
if (ka < kb) return mode === 'name' ? -1 : 1;
if (ka > kb) return mode === 'name' ? 1 : -1;
return 0;
+33
View File
@@ -43,6 +43,7 @@
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -268,6 +269,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -276,6 +278,7 @@
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -330,6 +333,35 @@
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
if (!API) {
@@ -812,6 +844,7 @@
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
+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 "")
+36
View File
@@ -0,0 +1,36 @@
"""Palier 25 (0.37.0): tri « En attente de revue » du dashboard —
les projets (section Épinglés incluse) s'ordonnent par le nombre
de cartes dans la colonne de validation."""
from app import models
def test_dashboard_sort_offers_review_mode(client):
r = client.get("/")
assert r.status_code == 200
assert '<option value="review">🔍 En attente de revue ↓</option>' in r.text
def test_dashboard_cards_carry_review_count(client):
# Aucune carte en révision au départ.
r = client.get("/")
assert 'data-review="0"' in r.text
# Une carte passe en Révision : le compteur de SA carte monte.
models.move_issue("Projets/demo", 5, "review")
models.move_issue("Projets/demo", 1, "review")
r = client.get("/")
assert 'data-review="2"' in r.text
def test_dashboard_js_knows_review_mode(client):
# Le moteur de tri partagé est servi tel quel : le mode
# 'review' doit y figurer (tri décroissant sur data-review).
r = client.get("/static/js/dashboard.js")
assert r.status_code == 200
assert "mode === 'review'" in r.text
assert "data-review" in r.text
# Le sens : les modes numeriques trient en ka - kb (cles
# niees = plus grand compteur d\'abord). Sans cette
# branche, le comparateur generique inversait le tri :
# bug attrape en navigateur reel sur le tri historique.
assert "return ka - kb" in r.text