diff --git a/app/services/connectors.py b/app/services/connectors.py index a99e8e3..4ff8cf6 100644 --- a/app/services/connectors.py +++ b/app/services/connectors.py @@ -1,5 +1,15 @@ """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:/`` 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, plus des connecteurs **personnels** persistés (URL + clé) en base avec un ``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans @@ -18,7 +28,9 @@ import logging from app.config import settings from app.db import get_conn +from app.services import coffre_reference as coffre_module from app.services import oauth_connectors +from app.services.coffre_reference import CoffreWorkspaceMismatch from app.services.sso_provisioning import decrypt_secret, encrypt_secret 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'}") +#: 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: - """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: tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else [] except (ValueError, TypeError): 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 { "id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"], "url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]), "status": r["status"], "detail": r["detail"] or "", - "has_secret": bool(r["secret_encrypted"]), "oauth": False, - "tools_count": len(tools), + "has_secret": bool(stored or reference), "oauth": False, + "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, }) with get_conn() as conn: + cols = _select_cols(conn) rows = conn.execute( - "SELECT id, name, url, secret_encrypted, enabled, status, detail, " - "kind, auth, tools_json FROM agent_connectors ORDER BY id" + f"SELECT {cols} FROM agent_connectors ORDER BY id" ).fetchall() return out + [_row(r) for r in rows] def get(connector_id: int) -> dict | None: with get_conn() as conn: + cols = _select_cols(conn) r = conn.execute( - "SELECT id, name, url, secret_encrypted, enabled, status, detail, " - "kind, auth, tools_json FROM agent_connectors WHERE id=?", + f"SELECT {cols} FROM agent_connectors WHERE id=?", (connector_id,), ).fetchone() 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) ──────────────────────────────────────────────────────── def create_connector(name: str, url: str, secret: str = "", *, - kind: str = "custom", auth: str = "bearer") -> dict: - """Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).""" + kind: str = "custom", auth: str = "bearer", + 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 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)})") if auth not in 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() - 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}") url = _validate_url(url) # lève ValueError si hôte interne 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( "INSERT INTO agent_connectors (name, url, secret_encrypted, status, " - "detail, kind, auth) VALUES (?,?,?,?,?,?,?)", - (name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth), + f"detail, kind, auth{extra_cols}) VALUES (?,?,?,?,?,?,?{',?' * len(extra_vals)})", + (name, url, encrypt_secret(secret or "") if not reference else "", + "unknown", "", kind, auth, *extra_vals), ) conn.commit() cid = cur.lastrowid 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) if row is None: return None @@ -206,7 +319,40 @@ def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) if enabled is not None: sets.append("enabled=?") 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 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=?") params.append(encrypt_secret(str(secret))) if sets: @@ -226,22 +372,72 @@ def delete_connector(connector_id: int) -> bool: # ── Réseau (toujours gardé SSRF) ───────────────────────────────────────────── -def _headers(connector_id: int | None = None) -> dict: - """En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée. +def resolve_secret(connector_id: int, *, workspace_id: int | None = None, + 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). + 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"} secret, auth = "", "bearer" if connector_id: - with get_conn() as conn: - r = conn.execute( - "SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?", - (connector_id,), - ).fetchone() - if r: - secret = decrypt_secret(r["secret_encrypted"] or "") - auth = r["auth"] or "bearer" + row = get(connector_id) + if row: + auth = row["auth"] or "bearer" + secret = resolve_secret(connector_id, workspace_id=workspace_id, + distribute=distribute) if secret and auth == "bot": headers["Authorization"] = f"Bot {secret}" elif secret and auth != "none": @@ -250,16 +446,13 @@ def _headers(connector_id: int | None = None) -> dict: 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).""" if "{secret}" not in url: return url - with get_conn() as conn: - r = conn.execute( - "SELECT secret_encrypted FROM agent_connectors WHERE id=?", - (connector_id,), - ).fetchone() - secret = decrypt_secret((r["secret_encrypted"] if r else "") or "") + secret = resolve_secret(connector_id, workspace_id=workspace_id, + distribute=distribute) if not secret: raise ValueError("clé manquante pour une URL contenant {secret}") return url.replace("{secret}", secret) @@ -303,13 +496,20 @@ def _resolve(connector: str) -> tuple[str, str | int]: raise ValueError(f"Connecteur inconnu: {token}") -async def probe(connector: str, user_id: int | None = None) -> dict: - """Teste un connecteur et **persiste** le résultat (personnel uniquement).""" +async def probe(connector: str, user_id: int | None = None, *, + 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) try: if kind in MCP_KINDS: 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)" elif kind in OAUTH_KINDS: 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] else: row = get(int(ref)) - url = _with_secret(row["url"], int(ref)) - code, _ = await _get(url, _headers(int(ref))) + url = _with_secret(row["url"], int(ref), workspace_id=workspace_id, + 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}" except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI 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 = "", - 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.""" kind, ref = _resolve(connector) path = (path or "").strip() @@ -381,10 +585,12 @@ async def connector_fetch(connector: str, path: str = "", query: str = "", return {"status": "error", "text": "Connecteur supprimé"} if not row["enabled"]: 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: 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: return {"status": "error", "text": f"HTTP {code} — {text[:400]}"} return {"status": "ok", "text": text[:FETCH_LIMIT]}