Kanbante 0.47.0 — HUB-23 : métadonnées des jetons kbt_ pour le registre du Hub (GET /api/jetons/metadonnees, cookie draco relayé, métadonnées seulement)
CI / test (pull_request) Failing after 1s
CI / test (push) Failing after 2m34s

This commit is contained in:
Spark
2026-11-06 17:01:31 +00:00
parent bb0cc2c72b
commit 8993656948
5 changed files with 228 additions and 2 deletions
+9
View File
@@ -1,3 +1,12 @@
## 0.47.0 — 2026-11-06 — Métadonnées des jetons pour le registre du Hub (HUB-23, carte fusion #137)
Version **mineure** : c'est une fonction (nouvelle route de lecture), selon le patron du dépôt.
- **Nouvelle route `GET /api/jetons/metadonnees`** : métadonnées des jetons personnels `kbt_` pour l'écran « Jetons API » du Hub — nom, propriétaire, préfixe `kbt_`, création, dernier usage, état (actif / révoqué). **Métadonnées seulement** : la valeur d'un jeton n'existe plus après sa création et l'empreinte SHA-256 ne quitte jamais la base (la sélection du modèle `list_all_api_tokens()` est explicite, colonne par colonne, sans `token_hash`).
- **Authentification** : cookie `draco_session` relayé côté serveur par le Hub — même patron que `/api/search` (le middleware admet la voie draco pour cette route ; correspondance par utilisateur HUB-16 exigée, **aucun JIT**, amorçage de l'unique compte admin comme sur /api/search). Un **administrateur** Kanbante reçoit les métadonnées de tous les jetons (`portee: "tous"`) ; tout autre compte, seulement les siennes (`portee: "propres"`). Sans cookie valide ou sans correspondance : 401.
- **Création et révocation inchangées** : elles restent dans Kanbante (page Profil) ; le Hub ne fait que lier vers elle.
- Tests : `tests/test_hub_jetons.py` — 401 sans cookie / sans correspondance, membre limité à ses jetons (états actif/révoqué), administrateur voyant tous les jetons avec propriétaires, absence de valeur et d'empreinte dans la réponse. Fonds comparés : suite complète, 20 échecs préexistants identiques sur `main` et sur la branche (environnement de test sans pytest-asyncio, fonds `test_palier*`/`test_mobile`), 393 → 396 réussis.
## 0.46.2 — 2026-11-06 — Rôles du Hub propagés à Kanbante (HUB-19, suite du pilote ShaarIt 0.7.0)
Version **corrective** au sens du patron du dépôt (pas de fonction nouvelle côté métier : un garde d'accès branché sur le registre du Hub, contrat D3(a) du plan Hub v2).
+1 -1
View File
@@ -1 +1 @@
0.46.2
0.47.0
+54 -1
View File
@@ -439,7 +439,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
# lieu à la première connexion par le pont /auth/draco.
# Les preflights OPTIONS ne portent jamais de cookies : ils
# sont répondus par RechercheCorsMiddleware avant d'arriver ici.
if path == "/api/search":
if path in ("/api/search", "/api/jetons/metadonnees"):
try:
from app import accounts as accounts_service
from app import draco
@@ -7160,6 +7160,59 @@ async def api_projects():
return {"active": active, "inactive": inactive, "archived": archived}
@app.get("/api/jetons/metadonnees")
async def api_jetons_metadonnees(request: Request):
"""Métadonnées des jetons `kbt_` pour le registre du Hub (HUB-23).
Le Hub interroge cette route côté serveur en relayant le cookie
`draco_session` de l'administrateur connecté (même patron que
/api/search : correspondance par utilisateur exigée, aucun JIT —
l'amorçage de l'unique compte admin reste possible, comme sur
/api/search). Un administrateur Kanbante reçoit les métadonnées
de tous les jetons ; tout autre compte, seulement les siennes.
Métadonnées seulement : nom, propriétaire, préfixe `kbt_`,
création, dernier usage, état (actif / révoqué). La valeur du
jeton n'existe plus après sa création et l'empreinte SHA-256 ne
quitte jamais la base : aucun des deux ne figure ici.
"""
from app import accounts as accounts_service
from app import draco
claims = draco.claims_for(request)
user = (
accounts_service.resolve_draco_user(claims)
if claims is not None
else None
)
if user is None:
return JSONResponse(
{"ok": False, "error": "Authentification draco requise"},
status_code=401,
)
if user["role"] == "admin":
lignes = models.list_all_api_tokens()
portee = "tous"
else:
lignes = [
{**t, "proprietaire": user["username"]}
for t in models.list_api_tokens(user["id"])
]
portee = "propres"
jetons = [
{
"nom": t["name"],
"proprietaire": t["proprietaire"],
"prefixe": models.API_TOKEN_PREFIX,
"cree_le": t["created_at"],
"dernier_usage": t["last_used_at"],
"etat": "revoque" if t["revoked_at"] else "actif",
}
for t in lignes
]
return {"jetons": jetons, "portee": portee}
@app.get("/api/health")
async def health():
"""Liveness probe: the process is up. Deliberately dependency-free."""
+19
View File
@@ -969,6 +969,25 @@ def list_api_tokens(user_id: int) -> list[dict]:
]
def list_all_api_tokens() -> list[dict]:
"""Métadonnées de TOUS les jetons (registre du Hub, HUB-23).
Jointure avec le compte propriétaire. La sélection est explicite,
colonne par colonne : l'empreinte (`token_hash`) n'en fait jamais
partie — cette fonction ne doit alimenter que des écrans de
métadonnées, jamais une surface d'authentification."""
with get_db() as conn:
return [
dict(r)
for r in conn.execute(
"SELECT t.id, t.name, t.created_at, t.last_used_at,"
" t.revoked_at, u.username AS proprietaire"
" FROM api_tokens t JOIN users u ON u.id = t.user_id"
" ORDER BY u.username, t.id"
).fetchall()
]
def revoke_api_token(user_id: int, token_id: int) -> bool:
with get_db() as conn:
cur = conn.execute(
+145
View File
@@ -0,0 +1,145 @@
"""HUB-23 — métadonnées des jetons `kbt_` pour le registre du Hub.
Route `GET /api/jetons/metadonnees` : le Hub l'interroge côté
serveur en relayant le cookie `draco_session` de l'administrateur
connecté (patron /api/search, correspondance HUB-16, aucun JIT).
Couvert ici :
- sans cookie draco, ou cookie sans correspondance : 401 ;
- un membre ne reçoit que ses propres jetons ;
- un administrateur Kanbante reçoit tous les jetons (portée
« tous »), propriétaires joints ;
- la réponse ne contient que des métadonnées : ni valeur de jeton,
ni empreinte SHA-256 (`token_hash`), sous aucune clé ;
- l'état distingue actif / révoqué ; le préfixe est `kbt_`.
La base de test est partagée par toute la session (voir conftest) :
les comptes créés ici portent des identifiants uniques « hub23-… »,
liés par `draco_sub` posé directement, et sont supprimés au
teardown de chaque test.
"""
import base64
import hashlib
import hmac
import json
import time
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, draco, models
CLE_TEST = "hub23-" + "cle-de-test"
def _jeton(sub, nom="Alice Exemple", secret=CLE_TEST):
def b64(d: bytes) -> str:
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64(json.dumps({
"sub": sub, "name": nom, "iat": int(time.time()),
"exp": int(time.time()) + 3600, "jti": f"jti-{sub}",
}).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest()
return f"{header}.{payload}.{b64(sig)}"
def _creer_lie(username: str, sub: str, role: str = "member") -> dict:
uid = models.create_user(
username, accounts.hash_password("motdepasse-local"),
role=role, display_name=username,
)
models.update_user(uid, draco_sub=sub)
return models.get_user(uid)
def _nettoyer():
with models.get_db() as conn:
ids = [
r[0] for r in conn.execute(
"SELECT id FROM users WHERE username LIKE 'hub23-%'"
).fetchall()
]
for uid in ids:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute(
"DELETE FROM workspace_members WHERE user_id = ?", (uid,)
)
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
@pytest.fixture()
def client_hub23(monkeypatch):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
monkeypatch.setenv("DRACO_SESSION_SECRET", CLE_TEST)
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
draco._revoked_cache.clear()
_nettoyer()
with TestClient(main.app) as client:
yield client
_nettoyer()
draco._revoked_cache.clear()
def test_sans_cookie_ou_sans_correspondance_401(client_hub23):
# Un compte lié existe d'abord : l'amorçage de l'unique compte
# admin (patron /api/search) ne peut pas lier le sub inconnu.
_creer_lie("hub23-temoin", "hub23-temoin")
assert client_hub23.get("/api/jetons/metadonnees").status_code == 401
client_hub23.cookies.set(
"draco_session", _jeton("hub23-inconnu", "Sans Lien")
)
r = client_hub23.get("/api/jetons/metadonnees")
assert r.status_code == 401
def test_membre_ne_voit_que_ses_jetons(client_hub23):
membre = _creer_lie("hub23-membre", "hub23-membre")
autre = _creer_lie("hub23-autre", "hub23-autre")
row, _clair = models.create_api_token(membre["id"], "service-agents")
models.create_api_token(autre["id"], "jeton-d-autrui")
models.revoke_api_token(membre["id"], row["id"])
_row2, _clair2 = models.create_api_token(membre["id"], "mcp-local")
client_hub23.cookies.set(
"draco_session", _jeton("hub23-membre", "Membre Exemple")
)
r = client_hub23.get("/api/jetons/metadonnees")
assert r.status_code == 200
corps = r.json()
assert corps["portee"] == "propres"
noms = {j["nom"]: j for j in corps["jetons"]}
assert set(noms) == {"service-agents", "mcp-local"}
assert noms["service-agents"]["etat"] == "revoque"
assert noms["mcp-local"]["etat"] == "actif"
assert noms["mcp-local"]["proprietaire"] == "hub23-membre"
assert noms["mcp-local"]["prefixe"] == "kbt_"
assert noms["mcp-local"]["cree_le"]
# Aucune valeur, aucune empreinte, sous aucune forme.
texte = json.dumps(corps)
assert "token_hash" not in texte
assert _clair not in texte and _clair2 not in texte
def test_admin_voit_tous_les_jetons(client_hub23):
admin = _creer_lie("hub23-admin", "hub23-admin", role="admin")
membre = _creer_lie("hub23-zoe", "hub23-zoe")
models.create_api_token(admin["id"], "jeton-admin")
models.create_api_token(membre["id"], "jeton-zoe")
client_hub23.cookies.set(
"draco_session", _jeton("hub23-admin", "Admin Exemple")
)
r = client_hub23.get("/api/jetons/metadonnees")
assert r.status_code == 200
corps = r.json()
assert corps["portee"] == "tous"
par_nom = {j["nom"]: j for j in corps["jetons"]}
assert par_nom["jeton-admin"]["proprietaire"] == "hub23-admin"
assert par_nom["jeton-zoe"]["proprietaire"] == "hub23-zoe"
assert "token_hash" not in json.dumps(corps)