Compare commits

...
1 Commits
Author SHA1 Message Date
Spark 133156580c 7.78.0 — MCP : périmètre du propriétaire du jeton
FlowDeck CI / lint (push) Waiting to run
FlowDeck CI / test (push) Waiting to run
FlowDeck CI / docker (push) Blocked by required conditions
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.
2026-10-22 09:22:19 -04:00
4 changed files with 101 additions and 18 deletions
+16
View File
@@ -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é
+1 -1
View File
@@ -1 +1 @@
7.77.0
7.78.0
+50 -17
View File
@@ -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)}]}}
+34
View File
@@ -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