diff --git a/CHANGELOG.md b/CHANGELOG.md index acc9c89..72adc31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/VERSION b/VERSION index 43c125a..421ab54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.2 +0.47.0 diff --git a/app/main.py b/app/main.py index b795f10..60f27ff 100644 --- a/app/main.py +++ b/app/main.py @@ -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.""" diff --git a/app/models.py b/app/models.py index f3694a5..51613ad 100644 --- a/app/models.py +++ b/app/models.py @@ -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( diff --git a/tests/test_hub_jetons.py b/tests/test_hub_jetons.py new file mode 100644 index 0000000..4c4751e --- /dev/null +++ b/tests/test_hub_jetons.py @@ -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)