diff --git a/CHANGELOG.md b/CHANGELOG.md index af83543..efcb39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog - FlowDeck +## v7.78.0 (2026-10-22) — MCP : périmètre du jeton (enfin) + +### Corrigé + +- **`list_workspaces` rendait les 52 workspaces de la base** — tous + les comptes et les résidus de tests : le MCP vérifiait le jeton + (qui tu es) mais n'appliquait aucun périmètre (ce que ce compte a + le droit de voir). Cas réel de Bruno, quelques heures après la + 7.77.0. Désormais : workspaces du propriétaire du jeton + (propriétaire ou membre, via `user_workspace_ids`), collections + filtrées par leur workspace effectif (direct ou via la page hôte ; + les collections sans rattachement restent visibles, comme dans + l'API publique historique), et tout objet hors périmètre répond + « introuvable » — lectures **et** écritures. Test de régression + à deux comptes. + ## v7.77.0 (2026-10-22) — Corbeille réparée + MCP : les workspaces ### Corrigé diff --git a/VERSION b/VERSION index dd22412..45d57c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.77.0 +7.78.0 diff --git a/app/routers/mcp_server.py b/app/routers/mcp_server.py index b4f7a57..b460486 100644 --- a/app/routers/mcp_server.py +++ b/app/routers/mcp_server.py @@ -28,15 +28,20 @@ from fastapi.responses import JSONResponse, Response from app.db import get_conn from app.routers.public_api import _token_owner +from app.services.collection_lifecycle import ( + effective_workspace_sql, + user_workspace_ids, +) router = APIRouter(tags=["mcp"]) OUTILS = [ {"name": "list_workspaces", - "description": "Liste les WORKSPACES Flowdeck — les conteneurs de pages " - "de l'utilisateur (ex. Techno, Famille Charest), avec leur " - "nombre de pages actives. À ne pas confondre avec les " - "collections (bases de données).", + "description": "Liste les WORKSPACES Flowdeck du propriétaire du jeton " + "(propriétaire ou membre) — ses conteneurs de pages " + "(ex. Techno, Famille Charest), avec leur nombre de pages " + "actives. À ne pas confondre avec les collections " + "(bases de données).", "inputSchema": {"type": "object", "properties": {}}}, {"name": "get_workspace", "description": "Lit un workspace et l'arborescence de ses pages " @@ -112,19 +117,41 @@ def _page_resume(row) -> dict: "updated_at": row["updated_at"]} -def _appel_outil(nom: str, args: dict): +def _appel_outil(nom: str, args: dict, user_id: int): + """Exécute un outil DANS LE PÉRIMÈTRE du propriétaire du jeton. + + Correctif 7.78.0 (cas réel de Bruno) : la première version listait + les 52 workspaces de la base — tous les comptes, résidus de tests + compris. Règle : workspaces dont l'utilisateur est propriétaire + ou membre ; collections rattachées (directement ou via leur page + hôte) à l'un de ces workspaces, plus les collections sans aucun + rattachement (héritage global, comme l'API publique historique). + Un objet hors périmètre répond « introuvable », sans fuiter son + existence. + """ with get_conn() as conn: + accessible = set(user_workspace_ids(conn, user_id)) + eff_sql = effective_workspace_sql("c") + + def collection_visible(coll_id: int) -> bool: + row = conn.execute( + f"SELECT {eff_sql} AS eff FROM collections c WHERE c.id = ?", + (coll_id,)).fetchone() + if row is None: + return False + return row["eff"] is None or row["eff"] in accessible + if nom == "list_workspaces": rows = conn.execute( "SELECT w.id, w.name, w.owner_id, " "(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id " " AND p.deleted_at IS NULL) AS page_count " "FROM workspaces w ORDER BY w.name").fetchall() - return [dict(r) for r in rows] + return [dict(r) for r in rows if r["id"] in accessible] if nom == "get_workspace": ws = conn.execute("SELECT * FROM workspaces WHERE id = ?", (int(args.get("id", 0)),)).fetchone() - if ws is None: + if ws is None or ws["id"] not in accessible: return {"erreur": "workspace introuvable"} pages = conn.execute( "SELECT id, title, parent_id, parent_section, updated_at " @@ -135,17 +162,20 @@ def _appel_outil(nom: str, args: dict): "pages": [dict(p) for p in pages]} if nom == "list_collections": rows = conn.execute( - "SELECT c.*, (SELECT COUNT(*) FROM collection_pages p " - "WHERE p.collection_id = c.id) AS page_count " + f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p " + f"WHERE p.collection_id = c.id) AS page_count, " + f"{eff_sql} AS eff_ws " "FROM collections c ORDER BY c.name").fetchall() return [{"id": r["id"], "name": r["name"], "description": r["description"], "icon": r["icon"], "is_task": bool(r["is_task"]) if "is_task" in r.keys() else False, - "page_count": r["page_count"]} for r in rows] + "page_count": r["page_count"]} + for r in rows + if r["eff_ws"] is None or r["eff_ws"] in accessible] if nom == "get_collection": coll = conn.execute("SELECT * FROM collections WHERE id = ?", (int(args.get("id", 0)),)).fetchone() - if coll is None: + if coll is None or not collection_visible(coll["id"]): return {"erreur": "collection introuvable"} pages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id = ? " @@ -157,7 +187,7 @@ def _appel_outil(nom: str, args: dict): if nom == "get_page": p = conn.execute("SELECT * FROM collection_pages WHERE id = ?", (int(args.get("id", 0)),)).fetchone() - if p is None: + if p is None or not collection_visible(p["collection_id"]): return {"erreur": "page introuvable"} res = _page_resume(p) for cle in ("content", "blocks", "blocks_json"): @@ -171,7 +201,8 @@ def _appel_outil(nom: str, args: dict): rows = conn.execute( "SELECT * FROM collection_pages WHERE title LIKE ? " "ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall() - return [_page_resume(p) for p in rows] + return [_page_resume(p) for p in rows + if collection_visible(p["collection_id"])] if nom == "list_repos": rows = conn.execute( "SELECT id, name, proj_type, owner, language, description " @@ -187,7 +218,7 @@ def _appel_outil(nom: str, args: dict): return {"erreur": "titre vide"} coll = conn.execute("SELECT * FROM collections WHERE id = ?", (collection_id,)).fetchone() - if coll is None: + if coll is None or not collection_visible(collection_id): return {"erreur": "collection introuvable"} props = args.get("properties") if props is None: @@ -214,7 +245,7 @@ def _appel_outil(nom: str, args: dict): return {"erreur": "id requis (entier)"} page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", (page_id,)).fetchone() - if page is None: + if page is None or not collection_visible(page["collection_id"]): return {"erreur": "page introuvable"} title = page["title"] if "title" in args and args.get("title") is not None: @@ -281,7 +312,8 @@ def mcp_info(): async def mcp(request: Request): auth = request.headers.get("authorization", "") token = auth[7:].strip() if auth.lower().startswith("bearer ") else "" - if not token or _token_owner(token) is None: + proprietaire = _token_owner(token) if token else None + if proprietaire is None: return JSONResponse( {"jsonrpc": "2.0", "id": None, "error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}}, @@ -308,7 +340,8 @@ async def mcp(request: Request): return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}} if methode == "tools/call": params = data.get("params") or {} - res = _appel_outil(params.get("name", ""), params.get("arguments") or {}) + res = _appel_outil(params.get("name", ""), params.get("arguments") or {}, + proprietaire["user_id"]) return {"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": json.dumps(res, ensure_ascii=False)}]}} diff --git a/tests/test_fusion_mcp_server.py b/tests/test_fusion_mcp_server.py index 3b3c211..f777410 100644 --- a/tests/test_fusion_mcp_server.py +++ b/tests/test_fusion_mcp_server.py @@ -195,6 +195,40 @@ def test_mcp_client_bearer_sans_session_ni_csrf(client, jeton, donnees): assert r.status_code == 401, r.text +def test_mcp_perimetre_du_jeton(client, jeton, donnees): + """Régression 7.78.0 (cas réel de Bruno) : list_workspaces rendait + les 52 workspaces de la base, tous comptes confondus. Chaque jeton + ne voit que les workspaces de son propriétaire (ou ceux dont il + est membre) et les collections rattachées à ce périmètre.""" + autre = "fd_token_autre_compte" + with get_conn() as conn: + conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (2, 'autre')") + conn.execute( + "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) " + "VALUES (2, 'autre', ?, ?, 0)", + (hashlib.sha256(autre.encode()).hexdigest(), autre[:12])) + cur = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES ('Espace de lautre', 2)") + wid_autre = cur.lastrowid + cur = conn.execute( + "INSERT INTO collections (name, description, icon, workspace_id) " + "VALUES ('Base de lautre', '', '📋', ?)", (wid_autre,)) + base_autre = cur.lastrowid + conn.commit() + # Bruno (jeton principal) ne voit ni le workspace ni la base de l'autre. + noms = [w["name"] for w in _appel(client, jeton, 80, "list_workspaces")] + assert "Espace de lautre" not in noms + res = _appel(client, jeton, 81, "get_workspace", {"id": wid_autre}) + assert "introuvable" in res["erreur"] + cols = [c["name"] for c in _appel(client, jeton, 82, "list_collections")] + assert "Base de lautre" not in cols and "Projet Alpha" in cols + res = _appel(client, jeton, 83, "get_collection", {"id": base_autre}) + assert "introuvable" in res["erreur"] + # Et l'autre ne voit pas le workspace de Bruno, mais voit le sien. + noms2 = [w["name"] for w in _appel(client, autre, 84, "list_workspaces")] + assert noms2 == ["Espace de lautre"] + + def test_mcp_info_get(client, jeton): r = client.get("/mcp") assert r.status_code == 200