7.78.0 — MCP : périmètre du propriétaire du jeton
list_workspaces/get_workspace filtrés par user_workspace_ids ; collections filtrées par workspace effectif ; hors périmètre = introuvable, lectures et écritures. Test deux comptes.
This commit is contained in:
@@ -1,5 +1,21 @@
|
|||||||
# Changelog - FlowDeck
|
# 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
|
## v7.77.0 (2026-10-22) — Corbeille réparée + MCP : les workspaces
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
+50
-17
@@ -28,15 +28,20 @@ from fastapi.responses import JSONResponse, Response
|
|||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.routers.public_api import _token_owner
|
from app.routers.public_api import _token_owner
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
effective_workspace_sql,
|
||||||
|
user_workspace_ids,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["mcp"])
|
router = APIRouter(tags=["mcp"])
|
||||||
|
|
||||||
OUTILS = [
|
OUTILS = [
|
||||||
{"name": "list_workspaces",
|
{"name": "list_workspaces",
|
||||||
"description": "Liste les WORKSPACES Flowdeck — les conteneurs de pages "
|
"description": "Liste les WORKSPACES Flowdeck du propriétaire du jeton "
|
||||||
"de l'utilisateur (ex. Techno, Famille Charest), avec leur "
|
"(propriétaire ou membre) — ses conteneurs de pages "
|
||||||
"nombre de pages actives. À ne pas confondre avec les "
|
"(ex. Techno, Famille Charest), avec leur nombre de pages "
|
||||||
"collections (bases de données).",
|
"actives. À ne pas confondre avec les collections "
|
||||||
|
"(bases de données).",
|
||||||
"inputSchema": {"type": "object", "properties": {}}},
|
"inputSchema": {"type": "object", "properties": {}}},
|
||||||
{"name": "get_workspace",
|
{"name": "get_workspace",
|
||||||
"description": "Lit un workspace et l'arborescence de ses pages "
|
"description": "Lit un workspace et l'arborescence de ses pages "
|
||||||
@@ -112,19 +117,41 @@ def _page_resume(row) -> dict:
|
|||||||
"updated_at": row["updated_at"]}
|
"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:
|
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":
|
if nom == "list_workspaces":
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT w.id, w.name, w.owner_id, "
|
"SELECT w.id, w.name, w.owner_id, "
|
||||||
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id "
|
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id "
|
||||||
" AND p.deleted_at IS NULL) AS page_count "
|
" AND p.deleted_at IS NULL) AS page_count "
|
||||||
"FROM workspaces w ORDER BY w.name").fetchall()
|
"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":
|
if nom == "get_workspace":
|
||||||
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
|
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
|
||||||
(int(args.get("id", 0)),)).fetchone()
|
(int(args.get("id", 0)),)).fetchone()
|
||||||
if ws is None:
|
if ws is None or ws["id"] not in accessible:
|
||||||
return {"erreur": "workspace introuvable"}
|
return {"erreur": "workspace introuvable"}
|
||||||
pages = conn.execute(
|
pages = conn.execute(
|
||||||
"SELECT id, title, parent_id, parent_section, updated_at "
|
"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]}
|
"pages": [dict(p) for p in pages]}
|
||||||
if nom == "list_collections":
|
if nom == "list_collections":
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
|
f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
|
||||||
"WHERE p.collection_id = c.id) AS page_count "
|
f"WHERE p.collection_id = c.id) AS page_count, "
|
||||||
|
f"{eff_sql} AS eff_ws "
|
||||||
"FROM collections c ORDER BY c.name").fetchall()
|
"FROM collections c ORDER BY c.name").fetchall()
|
||||||
return [{"id": r["id"], "name": r["name"],
|
return [{"id": r["id"], "name": r["name"],
|
||||||
"description": r["description"], "icon": r["icon"],
|
"description": r["description"], "icon": r["icon"],
|
||||||
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
|
"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":
|
if nom == "get_collection":
|
||||||
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
||||||
(int(args.get("id", 0)),)).fetchone()
|
(int(args.get("id", 0)),)).fetchone()
|
||||||
if coll is None:
|
if coll is None or not collection_visible(coll["id"]):
|
||||||
return {"erreur": "collection introuvable"}
|
return {"erreur": "collection introuvable"}
|
||||||
pages = conn.execute(
|
pages = conn.execute(
|
||||||
"SELECT * FROM collection_pages WHERE collection_id = ? "
|
"SELECT * FROM collection_pages WHERE collection_id = ? "
|
||||||
@@ -157,7 +187,7 @@ def _appel_outil(nom: str, args: dict):
|
|||||||
if nom == "get_page":
|
if nom == "get_page":
|
||||||
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||||
(int(args.get("id", 0)),)).fetchone()
|
(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"}
|
return {"erreur": "page introuvable"}
|
||||||
res = _page_resume(p)
|
res = _page_resume(p)
|
||||||
for cle in ("content", "blocks", "blocks_json"):
|
for cle in ("content", "blocks", "blocks_json"):
|
||||||
@@ -171,7 +201,8 @@ def _appel_outil(nom: str, args: dict):
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM collection_pages WHERE title LIKE ? "
|
"SELECT * FROM collection_pages WHERE title LIKE ? "
|
||||||
"ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
|
"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":
|
if nom == "list_repos":
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, proj_type, owner, language, description "
|
"SELECT id, name, proj_type, owner, language, description "
|
||||||
@@ -187,7 +218,7 @@ def _appel_outil(nom: str, args: dict):
|
|||||||
return {"erreur": "titre vide"}
|
return {"erreur": "titre vide"}
|
||||||
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
||||||
(collection_id,)).fetchone()
|
(collection_id,)).fetchone()
|
||||||
if coll is None:
|
if coll is None or not collection_visible(collection_id):
|
||||||
return {"erreur": "collection introuvable"}
|
return {"erreur": "collection introuvable"}
|
||||||
props = args.get("properties")
|
props = args.get("properties")
|
||||||
if props is None:
|
if props is None:
|
||||||
@@ -214,7 +245,7 @@ def _appel_outil(nom: str, args: dict):
|
|||||||
return {"erreur": "id requis (entier)"}
|
return {"erreur": "id requis (entier)"}
|
||||||
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||||
(page_id,)).fetchone()
|
(page_id,)).fetchone()
|
||||||
if page is None:
|
if page is None or not collection_visible(page["collection_id"]):
|
||||||
return {"erreur": "page introuvable"}
|
return {"erreur": "page introuvable"}
|
||||||
title = page["title"]
|
title = page["title"]
|
||||||
if "title" in args and args.get("title") is not None:
|
if "title" in args and args.get("title") is not None:
|
||||||
@@ -281,7 +312,8 @@ def mcp_info():
|
|||||||
async def mcp(request: Request):
|
async def mcp(request: Request):
|
||||||
auth = request.headers.get("authorization", "")
|
auth = request.headers.get("authorization", "")
|
||||||
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
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(
|
return JSONResponse(
|
||||||
{"jsonrpc": "2.0", "id": None,
|
{"jsonrpc": "2.0", "id": None,
|
||||||
"error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}},
|
"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}}
|
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
|
||||||
if methode == "tools/call":
|
if methode == "tools/call":
|
||||||
params = data.get("params") or {}
|
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,
|
return {"jsonrpc": "2.0", "id": rid,
|
||||||
"result": {"content": [{"type": "text",
|
"result": {"content": [{"type": "text",
|
||||||
"text": json.dumps(res, ensure_ascii=False)}]}}
|
"text": json.dumps(res, ensure_ascii=False)}]}}
|
||||||
|
|||||||
@@ -195,6 +195,40 @@ def test_mcp_client_bearer_sans_session_ni_csrf(client, jeton, donnees):
|
|||||||
assert r.status_code == 401, r.text
|
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):
|
def test_mcp_info_get(client, jeton):
|
||||||
r = client.get("/mcp")
|
r = client.get("/mcp")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user