Compare commits

..
4 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
Spark f6782ee5b3 7.77.0 — corbeille réparée (FK favorites/recents) + MCP workspaces
FlowDeck CI / test (push) Waiting to run
FlowDeck CI / docker (push) Blocked by required conditions
FlowDeck CI / lint (push) In progress
- detach_page_references : nettoyage des références NO ACTION avant
  toute suppression définitive (unitaire, Empty Trash, purge 30 j)
- MCP : list_workspaces / get_workspace ; descriptions collections
  clarifiées (bases de données, pas des workspaces)
- 96 tests verts sur le périmètre (MCP, trash, cohérence)
2026-10-22 09:03:43 -04:00
Spark 215990adc0 doc : entrée CHANGELOG 7.76.1
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / test (push) Failing after 22m38s
FlowDeck CI / docker (push) Skipped
2026-10-22 08:42:52 -04:00
Spark fae335f808 7.76.1 — correctif : /mcp exempté du CSRF (client Bearer machine-à-machine)
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / test (push) Failing after 22m55s
FlowDeck CI / docker (push) Skipped
Le middleware CSRF rejetait tout POST /mcp en 403 avant la route ;
les tests passaient car leur client porte session + CSRF. Test de
régression : client anonyme Bearer seul (200 avec jeton, 401 sans).
2026-10-22 08:42:42 -04:00
9 changed files with 270 additions and 19 deletions
+53
View File
@@ -1,5 +1,58 @@
# 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é
- **La suppression définitive depuis la corbeille échouait (500)**
pour toute page réelle : `favorites.page_id` et `recents.page_id`
référencent `pages(id)` sans CASCADE, avec les clés étrangères
actives — un `IntegrityError` dès que la page avait été ouverte
ou mise en favori. La purge automatique des 30 jours mourait sur
la même erreur, en silence. Nouveau nettoyage partagé
`detach_page_references`, appliqué aux trois chemins (suppression
unitaire, Empty Trash, purge planifiée). Cas réel de Bruno :
4 pages impossibles à supprimer. Test de régression ajouté.
### Ajouté
- **MCP : `list_workspaces` et `get_workspace`** — les workspaces
(conteneurs de pages : Techno, Famille Charest…) sont enfin
exposés au service d'agents, et les descriptions des outils
collections disent désormais clairement qu'une collection est une
base de données, pas un workspace (la réponse « 17 collections
(espaces de travail) » du panneau a induit Bruno en erreur).
## v7.76.1 (2026-10-22) — Correctif : le MCP était bloqué par le CSRF
### Corrigé
- **Tout client MCP réel recevait 403 sur `initialize`** : le
middleware CSRF exigeait un jeton de session pour tout POST hors
préfixes exemptés, et `/mcp` n'y était pas — alors que c'est du
machine-à-machine authentifié par jeton d'API (Bearer), comme
`/scim/v2`. Les tests ne voyaient rien : leur client porte une
session et un CSRF. `/mcp` rejoint les préfixes exemptés du CSRF
(son authentification propre est inchangée : sans jeton valide,
toujours 401). Test de régression ajouté : client Bearer anonyme,
sans session ni CSRF.
## v7.76.0 (2026-10-21) — Fusion : serveur MCP en écriture
### Ajouté
+1 -1
View File
@@ -1 +1 @@
7.76.0
7.78.0
+6 -1
View File
@@ -20,6 +20,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
# Même logique pour ``/mcp`` (Fusion, 7.76.1) : le serveur MCP est
# machine-à-machine, authentifié par les jetons d'API personnels
# (Bearer) — le service d'agents ne peut pas envoyer de X-CSRF-Token.
# Sans cette exemption, tout POST /mcp mourait en 403 avant la route
# (les tests passaient : leur client porte session + CSRF).
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
@@ -30,7 +35,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# - publics : /s/ (sites), /f/ (forms)
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
EXCLUDED_PATHS = {
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/mcp",
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
+3
View File
@@ -16,6 +16,7 @@ from app.services.collection_lifecycle import (
collections_hosted_by_page,
collections_hosted_by_pages,
delete_collections,
detach_page_references,
)
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
@@ -199,6 +200,7 @@ def permanent_delete(request: Request, page_id: int):
# tâches sans document.
collections = collections_hosted_by_page(conn, page_id)
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
detach_page_references(conn, [page_id])
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
delete_collections(conn, collections)
conn.commit()
@@ -229,6 +231,7 @@ def empty_trash(request: Request):
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
)
detach_page_references(conn, trashed)
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
delete_collections(conn, collections)
conn.commit()
+85 -16
View File
@@ -10,9 +10,11 @@ Authentification : les jetons d'API personnels existants
Le service d'agents reçoit un jeton dédié créé par l'utilisateur ;
il lit et écrit en son nom.
Objet exposé : les **collections** (les projets/espaces de travail
de Flowdeck) et leurs pages, la recherche par titre, et le registre
des dépôts (table ``projects``). Doctrine du programme : Flowdeck
Objets exposés : les **workspaces** (conteneurs de pages) et leurs
pages, les **collections** (bases de données type Notion — ne pas
confondre : une collection vit dans un workspace ou une page), la
recherche par titre, et le registre des dépôts (table ``projects``).
Doctrine du programme : Flowdeck
référence les tâches, il ne les copie pas — aucune tâche TikTak ne
transite par ici. Écritures limitées à la création/mise à jour de
pages et à la création de collections ; aucune suppression.
@@ -26,13 +28,33 @@ 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 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 "
"(titres, parent, section) — pages classiques, pas les "
"lignes des collections.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "list_collections",
"description": "Liste les collections Flowdeck (projets / espaces de travail) "
"avec leur nombre de pages.",
"description": "Liste les COLLECTIONS Flowdeck — les bases de données "
"de type Notion (tables, CRM, calendriers, formulaires), "
"avec leur nombre de lignes. Ce ne sont PAS les workspaces : "
"une collection vit dans un workspace ou dans une page.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_collection",
"description": "Lit une collection et ses pages (titres et propriétés).",
@@ -95,21 +117,65 @@ 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 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 or ws["id"] not in accessible:
return {"erreur": "workspace introuvable"}
pages = conn.execute(
"SELECT id, title, parent_id, parent_section, updated_at "
"FROM pages WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY parent_id, title LIMIT 300", (ws["id"],)).fetchall()
return {"workspace": {"id": ws["id"], "name": ws["name"],
"owner_id": ws["owner_id"]},
"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 = ? "
@@ -121,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"):
@@ -135,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 "
@@ -151,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:
@@ -178,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:
@@ -245,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)"}},
@@ -272,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)}]}}
+20
View File
@@ -51,6 +51,26 @@ EFFECTIVE_WORKSPACE_SQL = (
)
def detach_page_references(conn, page_ids) -> None:
"""Supprime les lignes qui référencent des pages en ``NO ACTION``.
``favorites.page_id`` et ``recents.page_id`` référencent
``pages(id)`` sans CASCADE et la connexion applique
``PRAGMA foreign_keys=ON`` : supprimer définitivement une page
réelle — ouverte un jour (recents) ou mise en favori — levait un
``IntegrityError`` (500 côté API, échec silencieux de la purge
automatique des 30 jours). À appeler avant tout
``DELETE FROM pages`` définitif. (Régression du 2026-10-22 :
les pages synthétiques des tests n'avaient ni favori ni récent.)
"""
ids = [int(i) for i in page_ids]
if not ids:
return
ph = ",".join("?" * len(ids))
conn.execute(f"DELETE FROM favorites WHERE page_id IN ({ph})", ids)
conn.execute(f"DELETE FROM recents WHERE page_id IN ({ph})", ids)
def effective_workspace_sql(alias: str = "c") -> str:
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
+2
View File
@@ -16,6 +16,7 @@ from app.db import get_conn
from app.services.collection_lifecycle import (
collections_hosted_by_page,
delete_collections,
detach_page_references,
)
logger = logging.getLogger(__name__)
@@ -71,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
"WHERE parent_id=?",
(page_id, page_id),
)
detach_page_references(conn, [page_id])
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
delete_collections(conn, collections)
purged.append(page_id)
+74 -1
View File
@@ -68,11 +68,35 @@ def test_mcp_handshake_et_outils(client, jeton):
assert r.json()["result"]["serverInfo"]["name"] == "flowdeck"
r = _rpc(client, jeton, 2, "tools/list")
noms = [t["name"] for t in r.json()["result"]["tools"]]
assert noms == ["list_collections", "get_collection", "get_page",
assert noms == ["list_workspaces", "get_workspace",
"list_collections", "get_collection", "get_page",
"search_pages", "list_repos", "create_page",
"update_page", "create_collection"]
def test_mcp_workspaces(client, jeton):
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Techno', 1)")
wid = cur.lastrowid
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section) "
"VALUES ('Techno', ?, 'Page racine', 'Private')", (wid,))
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section, deleted_at) "
"VALUES ('Techno', ?, 'Page corbeille', 'Private', '2026-10-11 10:00:00')", (wid,))
conn.commit()
ws = _appel(client, jeton, 70, "list_workspaces")
techno = [w for w in ws if w["name"] == "Techno"]
assert techno and techno[0]["page_count"] == 1 # la corbeille ne compte pas
detail = _appel(client, jeton, 71, "get_workspace", {"id": wid})
assert detail["workspace"]["name"] == "Techno"
assert [p["title"] for p in detail["pages"]] == ["Page racine"]
res = _appel(client, jeton, 72, "get_workspace", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_collections_et_pages(client, jeton, donnees):
cols = _appel(client, jeton, 1, "list_collections")
alpha = [c for c in cols if c["name"] == "Projet Alpha"]
@@ -156,6 +180,55 @@ def test_mcp_ecriture_sans_jeton_401(client, donnees):
assert r.status_code == 401
def test_mcp_client_bearer_sans_session_ni_csrf(client, jeton, donnees):
"""Régression 7.76.1 : un vrai client MCP est machine-à-machine
(Bearer seul, ni session ni jeton CSRF) — exactement le service
d'agents. Le client de test habituel porte une session + un CSRF,
ce qui masquait le 403 du middleware en production."""
from tests.conftest import anon
anon(client)
r = _rpc(client, jeton, 60, "initialize", {})
assert r.status_code == 200, r.text
cols = _appel(client, jeton, 61, "list_collections")
assert any(c["name"] == "Projet Alpha" for c in cols)
r = _rpc(client, None, 62, "tools/list")
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
+26
View File
@@ -32,6 +32,32 @@ def _row(page_id):
).fetchone()
def test_permanent_delete_page_referenced_by_favorites_and_recents(client):
"""Régression 7.77.0 (cas réel de Bruno, 2026-10-22) : une page
réelle — ouverte (recents) et/ou favorite — ne pouvait plus être
supprimée définitivement : favorites.page_id et recents.page_id
référencent pages(id) sans CASCADE, PRAGMA foreign_keys=ON →
IntegrityError → 500 « Delete failed », et Empty Trash (une seule
transaction) échouait en bloc. Les pages synthétiques des anciens
tests n'avaient ni favori ni récent : elles passaient."""
from app.db import get_conn
login_test_client(client)
pid = _mk_page(title="Page réelle de Bruno")
with get_conn() as conn:
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (1, ?, 0)", (pid,))
conn.execute("INSERT INTO recents (user_id, page_id, workspace) VALUES (1, ?, 'x')", (pid,))
conn.commit()
r = client.post(f"/api/pages/{pid}/trash")
assert r.status_code == 200
r = client.delete(f"/board/api/trash/{pid}")
assert r.status_code == 200, r.text
assert _row(pid) is None
with get_conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM favorites WHERE page_id=?", (pid,)).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM recents WHERE page_id=?", (pid,)).fetchone()[0] == 0
def test_trash_requires_session(client):
pid = _mk_page()
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403