coffre ObsiGate Phase 4 (issue #22) : app/services/connectors.py
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-10-27 23:47:58 -04:00
parent 417c0a23d7
commit 4f13068c85
+244 -38
View File
@@ -1,5 +1,15 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF. """Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
Références ObsiGate (Phase 4, issue ObsiGate #22) : un connecteur personnel
peut pointer vers ``coffre:<slug>/<nom>`` au lieu d'un jeton chiffré local.
La référence est **toujours liée à un workspace_id** (voir
``app/services/coffre_reference.py``) ; à l'appel, la valeur est distribuée
en mémoire par le serveur puis oubliée, jamais persistée ni mise en cache.
Les secrets déjà chiffrés localement restent valides (migration
opportuniste, connecteur par connecteur — voir
``docs/coffre-reference-flowdeck.md``).
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée, 5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans ``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
@@ -18,7 +28,9 @@ import logging
from app.config import settings from app.config import settings
from app.db import get_conn from app.db import get_conn
from app.services import coffre_reference as coffre_module
from app.services import oauth_connectors from app.services import oauth_connectors
from app.services.coffre_reference import CoffreWorkspaceMismatch
from app.services.sso_provisioning import decrypt_secret, encrypt_secret from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -118,18 +130,62 @@ def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}") return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
#: Colonnes lues pour un connecteur personnel (les deux colonnes coffre
#: sont ajoutées par la migration 52 ; les bases antérieures les ignorent
#: via les requêtes tolérantes de `_select_cols` — voir ci-dessous).
_CONNECTOR_COLS = ("id", "name", "url", "secret_encrypted", "enabled", "status",
"detail", "kind", "auth", "tools_json", "workspace_id",
"coffre_reference")
def _select_cols(conn) -> str:
"""Liste SELECT tolérante : les colonnes coffre n'existent qu'après
la migration 52 (bases créées avant, ou tests à schéma figé)."""
try:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
except Exception: # noqa: BLE001 — repli : colonnes historiques seules
cols = set(_CONNECTOR_COLS[:10])
wanted = [c for c in _CONNECTOR_COLS if c in cols]
return ", ".join(wanted) if wanted else ", ".join(_CONNECTOR_COLS[:10])
def _row(r) -> dict: def _row(r) -> dict:
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais).""" """Ligne `agent_connectors` → dict API (le jeton n'y figure jamais).
``coffre_reference`` est une métadonnée (pas une valeur) : elle peut
figurer dans la réponse. ``secret_source`` indique la provenance :
``coffre`` (distribué à l'appel), ``chiffre_local`` (Fernet historique)
ou ``aucun``.
"""
try: try:
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else [] tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
except (ValueError, TypeError): except (ValueError, TypeError):
tools = [] tools = []
try:
reference = r["coffre_reference"] or ""
except (KeyError, IndexError):
reference = ""
try:
workspace_id = r["workspace_id"]
except (KeyError, IndexError):
workspace_id = None
try:
stored = r["secret_encrypted"]
except (KeyError, IndexError):
stored = ""
if reference:
source = "coffre"
elif stored:
source = "chiffre_local"
else:
source = "aucun"
return { return {
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"], "id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]), "url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
"status": r["status"], "detail": r["detail"] or "", "status": r["status"], "detail": r["detail"] or "",
"has_secret": bool(r["secret_encrypted"]), "oauth": False, "has_secret": bool(stored or reference), "oauth": False,
"tools_count": len(tools), "tools_count": len(tools), "workspace_id": workspace_id,
"coffre_reference": reference, "secret_source": source,
} }
@@ -146,28 +202,47 @@ def list_connectors(user_id: int | None = None) -> list[dict]:
"has_secret": False, "oauth": kind in OAUTH_KINDS, "has_secret": False, "oauth": kind in OAUTH_KINDS,
}) })
with get_conn() as conn: with get_conn() as conn:
cols = _select_cols(conn)
rows = conn.execute( rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, " f"SELECT {cols} FROM agent_connectors ORDER BY id"
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
).fetchall() ).fetchall()
return out + [_row(r) for r in rows] return out + [_row(r) for r in rows]
def get(connector_id: int) -> dict | None: def get(connector_id: int) -> dict | None:
with get_conn() as conn: with get_conn() as conn:
cols = _select_cols(conn)
r = conn.execute( r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, " f"SELECT {cols} FROM agent_connectors WHERE id=?",
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
(connector_id,), (connector_id,),
).fetchone() ).fetchone()
return _row(r) if r else None return _row(r) if r else None
def _workspace_exists(workspace_id: int) -> bool:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE id=?", (workspace_id,)
).fetchone()
return row is not None
# ── CRUD (personnels) ──────────────────────────────────────────────────────── # ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "", *, def create_connector(name: str, url: str, secret: str = "", *,
kind: str = "custom", auth: str = "bearer") -> dict: kind: str = "custom", auth: str = "bearer",
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).""" workspace_id: int | None = None,
coffre_reference: str | None = None) -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).
Variante coffre (Phase 4) : si ``coffre_reference`` est fourni, le
champ jeton **devient** ce champ référence — aucun jeton n'est stocké
(``secret_encrypted`` reste vide) et un ``workspace_id`` existant est
**obligatoire** (octroi toujours lié à un workspace). ``secret`` et
``coffre_reference`` sont exclusifs. Un connecteur sans référence peut
aussi recevoir un ``workspace_id`` (première étape d'une migration
opportuniste : rattacher d'abord, basculer ensuite).
"""
from app.services.importers.url_fetch import _validate_url from app.services.importers.url_fetch import _validate_url
name = (name or "").strip() name = (name or "").strip()
@@ -177,22 +252,60 @@ def create_connector(name: str, url: str, secret: str = "", *,
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})") raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
if auth not in AUTH_SCHEMES: if auth not in AUTH_SCHEMES:
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})") raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
reference = ""
if coffre_reference is not None and str(coffre_reference).strip():
reference = coffre_module.parse_reference(str(coffre_reference))
reference = f"coffre:{reference[0]}/{reference[1]}"
if (secret or "").strip():
raise ValueError("secret et coffre_reference sont exclusifs "
"(le jeton devient la référence)")
if workspace_id is None:
raise ValueError("coffre_reference exige un workspace_id "
"(octroi toujours lié à un workspace)")
if workspace_id is not None:
workspace_id = int(workspace_id)
if workspace_id <= 0 or not _workspace_exists(workspace_id):
raise ValueError(f"workspace introuvable: {workspace_id}")
url = (url or "").strip() url = (url or "").strip()
if "{secret}" in url and not (secret or "").strip(): if "{secret}" in url and not (secret or "").strip() and not reference:
raise ValueError("clé requise : l'URL contient {secret}") raise ValueError("clé requise : l'URL contient {secret}")
url = _validate_url(url) # lève ValueError si hôte interne url = _validate_url(url) # lève ValueError si hôte interne
with get_conn() as conn: with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
extra_cols, extra_vals = "", ()
if "workspace_id" in cols:
extra_cols += ", workspace_id"
extra_vals += (workspace_id,)
if "coffre_reference" in cols:
extra_cols += ", coffre_reference"
extra_vals += (reference,)
cur = conn.execute( cur = conn.execute(
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, " "INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)", f"detail, kind, auth{extra_cols}) VALUES (?,?,?,?,?,?,?{',?' * len(extra_vals)})",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth), (name, url, encrypt_secret(secret or "") if not reference else "",
"unknown", "", kind, auth, *extra_vals),
) )
conn.commit() conn.commit()
cid = cur.lastrowid cid = cur.lastrowid
return get(cid) return get(cid)
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None: _UNSET = object()
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None,
workspace_id=_UNSET, coffre_reference=_UNSET) -> dict | None:
"""Met à jour un connecteur personnel.
Règles coffre (Phase 4) :
* poser une référence exige un workspace (celui du connecteur, ou
celui passé dans le même appel) ; la référence et un jeton local
sont exclusifs dans le même appel ;
* un ``workspace_id`` déjà posé est **immuable** : on ne transfère pas
un octroi d'un workspace à l'autre en éditant le connecteur ;
* effacer la référence (``coffre_reference=""``) réactive l'éventuel
jeton local dormant, sans jamais le réécrire.
"""
row = get(connector_id) row = get(connector_id)
if row is None: if row is None:
return None return None
@@ -206,7 +319,40 @@ def update_connector(connector_id: int, *, name=None, enabled=None, secret=None)
if enabled is not None: if enabled is not None:
sets.append("enabled=?") sets.append("enabled=?")
params.append(1 if enabled else 0) params.append(1 if enabled else 0)
effective_workspace = row.get("workspace_id")
if workspace_id is not _UNSET:
if workspace_id is None:
raise ValueError("workspace_id ne peut pas être effacé")
workspace_id = int(workspace_id)
if workspace_id <= 0 or not _workspace_exists(workspace_id):
raise ValueError(f"workspace introuvable: {workspace_id}")
if row.get("workspace_id") is not None and row["workspace_id"] != workspace_id:
raise ValueError("workspace_id immuable : un connecteur ne change "
"pas de workspace (pas de transfert d'octroi)")
effective_workspace = workspace_id
sets.append("workspace_id=?")
params.append(workspace_id)
reference_cleared = False
if coffre_reference is not _UNSET:
if coffre_reference is None or not str(coffre_reference).strip():
reference_cleared = True
sets.append("coffre_reference=?")
params.append("")
else:
parsed = coffre_module.parse_reference(str(coffre_reference))
if effective_workspace is None:
raise ValueError("coffre_reference exige un workspace_id "
"(octroi toujours lié à un workspace)")
if secret is not None and str(secret).strip():
raise ValueError("secret et coffre_reference sont exclusifs "
"(le jeton devient la référence)")
sets.append("coffre_reference=?")
params.append(f"coffre:{parsed[0]}/{parsed[1]}")
if secret is not None: if secret is not None:
if row.get("coffre_reference") and not reference_cleared:
raise ValueError("ce connecteur pointe vers une référence ObsiGate : "
"effacez d'abord coffre_reference pour revenir "
"à un jeton local")
sets.append("secret_encrypted=?") sets.append("secret_encrypted=?")
params.append(encrypt_secret(str(secret))) params.append(encrypt_secret(str(secret)))
if sets: if sets:
@@ -226,22 +372,72 @@ def delete_connector(connector_id: int) -> bool:
# ── Réseau (toujours gardé SSRF) ───────────────────────────────────────────── # ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
def _headers(connector_id: int | None = None) -> dict: def resolve_secret(connector_id: int, *, workspace_id: int | None = None,
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée. distribute=None) -> str:
"""Résout le jeton d'un connecteur **en mémoire**, jamais persisté.
* Connecteur à référence : le ``workspace_id`` demandé doit être
celui du connecteur, sinon ``CoffreWorkspaceMismatch`` explicite
**avant tout appel** à ObsiGate (le distributeur n'est pas appelé).
La valeur distribuée est renvoyée à l'appelant, qui l'utilise puis
l'oublie — aucune mise en cache ici.
* Connecteur historique : déchiffrement Fernet local (migration
opportuniste : ces connecteurs restent valides tels quels).
* Sans jeton ni référence : chaîne vide.
"""
# Lecture directe en base : le dict API de get() n'expose jamais le
# jeton (chiffré ou non), c'est ici seulement qu'il est résolu.
with get_conn() as conn:
cols = _select_cols(conn)
r = conn.execute(
f"SELECT {cols} FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if r is None:
raise ValueError(f"Connecteur introuvable: {connector_id}")
try:
reference = r["coffre_reference"] or ""
except (KeyError, IndexError):
reference = ""
try:
owner = r["workspace_id"]
except (KeyError, IndexError):
owner = None
if reference:
if workspace_id is None:
raise ValueError("workspace_id est requis pour un connecteur "
"à référence ObsiGate")
if owner is None or int(owner) != int(workspace_id):
raise CoffreWorkspaceMismatch(
f"workspace {workspace_id} ne peut pas utiliser ce connecteur "
f"(il appartient au workspace {owner}) : octroi d'un autre "
"workspace refusé"
)
return coffre_module.distribuer(
reference, int(workspace_id), request=distribute)
try:
stored = r["secret_encrypted"]
except (KeyError, IndexError):
stored = ""
return decrypt_secret(stored or "")
def _headers(connector_id: int | None = None, *, workspace_id: int | None = None,
distribute=None) -> dict:
"""En-têtes d'appel : la clé n'est lue (et déchiffrée/distribuée) qu'ici.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs). ``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
Pour un connecteur à référence, ``workspace_id`` est obligatoire et
l'isolation est vérifiée par ``resolve_secret`` avant distribution.
""" """
headers = {"User-Agent": "FlowDeck-Connectors/1.0"} headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret, auth = "", "bearer" secret, auth = "", "bearer"
if connector_id: if connector_id:
with get_conn() as conn: row = get(connector_id)
r = conn.execute( if row:
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?", auth = row["auth"] or "bearer"
(connector_id,), secret = resolve_secret(connector_id, workspace_id=workspace_id,
).fetchone() distribute=distribute)
if r:
secret = decrypt_secret(r["secret_encrypted"] or "")
auth = r["auth"] or "bearer"
if secret and auth == "bot": if secret and auth == "bot":
headers["Authorization"] = f"Bot {secret}" headers["Authorization"] = f"Bot {secret}"
elif secret and auth != "none": elif secret and auth != "none":
@@ -250,16 +446,13 @@ def _headers(connector_id: int | None = None) -> dict:
return headers return headers
def _with_secret(url: str, connector_id: int) -> str: def _with_secret(url: str, connector_id: int, *, workspace_id: int | None = None,
distribute=None) -> str:
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair).""" """Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
if "{secret}" not in url: if "{secret}" not in url:
return url return url
with get_conn() as conn: secret = resolve_secret(connector_id, workspace_id=workspace_id,
r = conn.execute( distribute=distribute)
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if not secret: if not secret:
raise ValueError("clé manquante pour une URL contenant {secret}") raise ValueError("clé manquante pour une URL contenant {secret}")
return url.replace("{secret}", secret) return url.replace("{secret}", secret)
@@ -303,13 +496,20 @@ def _resolve(connector: str) -> tuple[str, str | int]:
raise ValueError(f"Connecteur inconnu: {token}") raise ValueError(f"Connecteur inconnu: {token}")
async def probe(connector: str, user_id: int | None = None) -> dict: async def probe(connector: str, user_id: int | None = None, *,
"""Teste un connecteur et **persiste** le résultat (personnel uniquement).""" workspace_id: int | None = None, distribute=None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement).
``workspace_id`` est transmis aux chemins à référence ObsiGate ; un
connecteur à référence testé sans workspace échoue proprement
(``error``), jamais avec une valeur d'un autre workspace.
"""
kind, ref = _resolve(connector) kind, ref = _resolve(connector)
try: try:
if kind in MCP_KINDS: if kind in MCP_KINDS:
from app.services import mcp_client from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref)) tools = await mcp_client.initialize_and_list(
int(ref), workspace_id=workspace_id, distribute=distribute)
status, detail = "ok", f"MCP · {len(tools)} outil(s)" status, detail = "ok", f"MCP · {len(tools)} outil(s)"
elif kind in OAUTH_KINDS: elif kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id, res = await oauth_connectors.api_get(kind, user_id,
@@ -326,8 +526,10 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
status, detail = "ok", native_state("web")[1] status, detail = "ok", native_state("web")[1]
else: else:
row = get(int(ref)) row = get(int(ref))
url = _with_secret(row["url"], int(ref)) url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
code, _ = await _get(url, _headers(int(ref))) distribute=distribute)
code, _ = await _get(url, _headers(int(ref), workspace_id=workspace_id,
distribute=distribute))
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}" status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
logger.info("Connector probe failed (%s): %s", connector, exc) logger.info("Connector probe failed (%s): %s", connector, exc)
@@ -351,7 +553,9 @@ def _gh_headers() -> dict:
async def connector_fetch(connector: str, path: str = "", query: str = "", async def connector_fetch(connector: str, path: str = "", query: str = "",
user_id: int | None = None) -> dict: user_id: int | None = None, *,
workspace_id: int | None = None,
distribute=None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET.""" """Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector) kind, ref = _resolve(connector)
path = (path or "").strip() path = (path or "").strip()
@@ -381,10 +585,12 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
return {"status": "error", "text": "Connecteur supprimé"} return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]: if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"} return {"status": "error", "text": "Connecteur désactivé"}
url = _with_secret(row["url"], int(ref)).rstrip("/") url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
distribute=distribute).rstrip("/")
if path: if path:
url += "/" + path.lstrip("/") url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref))) code, text = await _get(url, _headers(int(ref), workspace_id=workspace_id,
distribute=distribute))
if code >= 400: if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"} return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:FETCH_LIMIT]} return {"status": "ok", "text": text[:FETCH_LIMIT]}