Compare commits

...
Author SHA1 Message Date
bruno f5b007f286 barre-apps v0.1.6 : CSS vendange (menus, avatar bouton)
CI / test (push) Failing after 1s
CI / test (pull_request) Failing after 1s
2026-10-28 10:31:27 -04:00
bruno 2b9b852308 barre-apps v0.1.6 : JS vendange (derivation -dev, menus)
CI / test (push) Failing after 2s
2026-10-28 10:31:23 -04:00
bruno d3886b6bf5 barre-apps v0.1.6 : fragment (avatar bouton compte, menu par defaut demande, cfg.user initiale)
CI / test (push) Failing after 1s
2026-10-28 10:31:19 -04:00
bruno 671ee68c96 Merge pull request '[Coffre Secrets] Secret webhook par reference ObsiGate (Phase 4)' (#31) from feat/coffre-reference-obsigate into main
CI / test (push) Successful in 3m50s
2026-10-28 00:15:20 -04:00
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
8 changed files with 1205 additions and 51 deletions
+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 = ""
+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", "")
+17 -4
View File
@@ -1,5 +1,5 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
Snippet vendangé depuis Projets/dracodev-commons v0.1.4
Snippet vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : structure inchangée ; seule adaptation locale
(0.32.0, dev/prod) = les URL des apps et l'adresse du service
d'agents sont INJECTÉES par l'hôte depuis app.config
@@ -61,8 +61,8 @@
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" title="Opérateur Kanbante">{{ draco_avatar }}</span>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="Opérateur Kanbante">{{ draco_avatar }}</button>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
@@ -75,6 +75,14 @@
phases suivantes du programme Fusion. */
window.DRACO_BAR_CONFIG = {
current: "kanbante",
/* v0.1.6 : user = initiale auth_user ou K (draco_avatar). Sans
cfg.user, le nouveau JS dériverait l'initiale du title
("Opérateur Kanbante" → "O"), qui est un libellé de rôle et
non un nom. Contrepartie documentée : syncAvatar() réécrit
aussi le title avec le nom d'affichage, donc l'infobulle
devient l'initiale après chargement (le libellé reste dans
le HTML servi). */
user: "{{ draco_avatar }}",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
@@ -89,7 +97,12 @@
if (act === "rail") { var b = document.getElementById("side-collapse"); if (b) b.click(); return; }
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
/* v0.1.6 : le toast placeholder "fédération" est remplacé
par le menu par défaut du nouveau JS — retourner false le
demande explicitement (un onAction existant garde sinon la
main). Contenu : Console agent + applications, URL résolues
donc dérivées -dev sur un hôte -dev. */
if (act === "menu") { return false; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
+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>
+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.
+30 -3
View File
@@ -2,7 +2,9 @@
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
que le menu des applications) ; l'avatar est un vrai <button>.
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -42,6 +44,29 @@
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
.draco-more-menu, .draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
background: transparent; color: var(--ink); font: inherit; text-align: left;
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
border-bottom: 1px solid var(--line); margin-bottom: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
@@ -122,8 +147,10 @@
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
display: grid; place-items: center; flex: none;
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
cursor: pointer;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
+403 -40
View File
@@ -1,5 +1,10 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
* v0.1.5 : noms publics par configuration — aucun nom public d'un
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
* la publication est séparée) : dérivation -dev par l'hôte courant,
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -10,19 +15,158 @@
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
*
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
* résolution, dans l'ordre STRICT, pour chaque app :
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
* serveur ou client) depuis SA configuration d'environnement —
* explicite, donc prioritaire, même sur un hôte -dev ;
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
* (première étiquette se terminant par -dev : tiktak-dev,
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
* les noms SANS -dev faute de configuration explicite ;
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
* partout (config injectée comme href rendu) et ne peut pas être exposé.
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
* Spark (panneau-agent), qui suit le même contrat d'injection.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
*
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
* connu à gérer l'action "menu" via onAction (il déclenche son propre
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
* pas et le bouton restait mort (relais vers un onAction absent, puis
* événement draco:bar sans écouteur). Comportement désormais :
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
* la console agent (URL agent résolue, donc dérivée -dev sur un
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
* par clic extérieur, Échap ou choix d'un lien ;
* - avec onAction : l'hôte garde la main (comportement historique,
* zéro régression pour ObsiGate) ; il peut demander explicitement
* le menu par défaut en retournant false pour l'action "menu".
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
/* Défauts OFFICIELS (production) — les seuls noms publics présents
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
* volontairement absent : aucun hôte spark-dev connu, aucune URL
* inventée ; spark retombe sur href rendu puis défaut prod. */
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
* ne déclenche rien). Insensible à la casse. */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function resolveUrls() {
var host = "";
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
var dev = isDevHost(host);
var urls = {};
Object.keys(DEFAULT_URLS).forEach(function (app) {
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
var u = cleanUrl(cfg.urls && cfg.urls[app]);
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
if (!u && dev) u = DEV_URLS[app] || null;
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
if (!u) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var href = tab ? tab.getAttribute("href") : null;
u = cleanUrl(href);
}
/* 4. Défaut officiel (production). */
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function applyUrls(urls) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
});
}
var resolvedUrls = resolveUrls();
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -43,26 +187,122 @@
});
}
/* ── Menu des applications (v0.1.3) ──────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
* choix d'une application (la navigation suit son cours). */
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
function userName() {
var u = cfg.user;
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
if (u && typeof u === "object") {
var n = u.display_name || u.name || u.username;
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
}
return null;
}
function titleName() {
var av = document.querySelector(".draco-avatar");
var t = av ? av.getAttribute("title") : null;
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
return null;
}
function displayName() { return userName() || titleName(); }
function syncAvatar() {
var av = document.querySelector(".draco-avatar");
if (!av) return;
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
* sans data-act) : le rendre déclencheur du menu compte. */
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
var name = displayName();
if (name) {
av.textContent = name.charAt(0).toUpperCase();
av.setAttribute("title", name);
av.setAttribute("aria-label", "Compte : " + name);
}
}
/* ── Menus déroulants (apps, •••, compte) ──────────────────────────
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
* déclencheur. Les href des menus sont (re)synchronisés avec
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
* -dev s'y reflètent même si le menu a été construit avant. */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
var moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, moreMenu = null, compteMenu = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
}
function refreshMenuHrefs(menu) {
if (!menu) return;
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups() {
if (appsMenu) togglePopup(appsMenu, brand, false);
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
}
function tabLabel(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
return fort ? fort.textContent : (APP_LABELS[app] || app);
}
function tabRole(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
function menuLink(app, nom, role) {
var a = document.createElement("a");
a.className = "draco-apps-item draco-menu-item";
a.href = resolvedUrls[app] || "#";
a.dataset.app = app;
a.setAttribute("role", "menuitem");
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var n = document.createElement("span");
n.className = "draco-apps-item-nom";
n.textContent = nom;
txt.appendChild(n);
if (role) {
var r = document.createElement("span");
r.className = "draco-apps-item-role";
r.textContent = role;
txt.appendChild(r);
}
a.appendChild(txt);
return a;
}
/* Menu des applications (v0.1.3) — construit depuis les onglets
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
* extérieur, Échap, ou le choix d'une application (la navigation
* suit son cours). */
function buildAppsMenu() {
if (!bar || !brand || appsMenu) return;
appsMenu = document.createElement("div");
appsMenu.className = "draco-apps-menu";
appsMenu.hidden = true;
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
@@ -84,44 +324,167 @@
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
appsMenu.appendChild(a);
});
bar.appendChild(menu);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
function buildMoreMenu() {
if (!bar || !moreBtn || moreMenu) return;
moreMenu = document.createElement("div");
moreMenu.className = "draco-more-menu";
moreMenu.hidden = true;
moreMenu.setAttribute("role", "menu");
moreMenu.setAttribute("aria-label", "Plus d'actions");
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
moreMenu.appendChild(sep);
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
});
bar.appendChild(moreMenu);
}
function toggleMoreMenu(force) {
buildMoreMenu();
if (!moreMenu) return;
if (force !== false) refreshMenuHrefs(moreMenu);
togglePopup(moreMenu, moreBtn, force);
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
compteMenu.className = "draco-compte-menu";
compteMenu.hidden = true;
compteMenu.setAttribute("role", "menu");
compteMenu.setAttribute("aria-label", "Compte");
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
});
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
if (!compteMenu) return;
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
}
function toggleCompteMenu(force) {
buildCompteMenu();
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
}
/* Relais d'une action vers l'hôte : onAction si présent, sinon
* événement draco:bar (contrat historique des actions relayées). */
function relay(act, el, ev) {
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
toggleCompteMenu(false);
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (moreMenu) toggleMoreMenu(false);
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "menu") {
if (appsMenu) toggleAppsMenu(false);
if (compteMenu) toggleCompteMenu(false);
/* Hôte prioritaire : il garde la main, sauf s'il demande
* explicitement le menu par défaut en retournant false.
* Sans onAction, le menu par défaut s'ouvre (il ne restait
* auparavant qu'un événement draco:bar sans écouteur). */
if (typeof cfg.onAction === "function") {
var handled = cfg.onAction("menu", el, ev);
if (handled === false) toggleMoreMenu();
} else {
toggleMoreMenu();
}
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
if (moreMenu) toggleMoreMenu(false);
toggleCompteMenu();
return;
}
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
* sur son déclencheur déjà traité ci-dessus). */
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups();
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
relay(act, el, ev);
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
if (ev.key === "Escape") closeAllPopups();
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
/* API publique : l'app met les compteurs à jour sans recharger.
* setUrls(urls) fusionne de nouvelles bases (même contrat que
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
* les bases résolues (défauts inclus), pour le panneau Spark et tout
* autre consommateur de l'hôte. */
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
}
};
buildMenu();
applyUrls(resolvedUrls);
buildAppsMenu();
buildMoreMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+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 "")