Kanbante 0.47.0 — HUB-23 : métadonnées des jetons kbt_ pour le registre du Hub #87
@@ -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).
|
||||
|
||||
+54
-1
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user