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)
This commit is contained in:
@@ -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)
|
## 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).
|
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.
|
# lieu à la première connexion par le pont /auth/draco.
|
||||||
# Les preflights OPTIONS ne portent jamais de cookies : ils
|
# Les preflights OPTIONS ne portent jamais de cookies : ils
|
||||||
# sont répondus par RechercheCorsMiddleware avant d'arriver ici.
|
# sont répondus par RechercheCorsMiddleware avant d'arriver ici.
|
||||||
if path == "/api/search":
|
if path in ("/api/search", "/api/jetons/metadonnees"):
|
||||||
try:
|
try:
|
||||||
from app import accounts as accounts_service
|
from app import accounts as accounts_service
|
||||||
from app import draco
|
from app import draco
|
||||||
@@ -7160,6 +7160,59 @@ async def api_projects():
|
|||||||
return {"active": active, "inactive": inactive, "archived": archived}
|
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")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Liveness probe: the process is up. Deliberately dependency-free."""
|
"""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:
|
def revoke_api_token(user_id: int, token_id: int) -> bool:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = conn.execute(
|
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