Files
flowdeck/app/services/connectors.py
T
bruno 1d7750bda0
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 16m4s
FlowDeck CI / docker (push) Skipped
feat: connecteurs Google + Microsoft 365 (OAuth2 PKCE) — phase 6/8 (v7.56.0)
- app/services/oauth_connectors.py : flow OAuth2 complet PKCE (S256) pour
  2 fournisseurs décrits par 1 dict — Google (Drive/Gmail/Calendar en lecture
  seule) et Microsoft 365 (Graph Files.Read / Mail.Read / Calendars.Read) ;
  begin() = URL d'autorisation + state + code_verifier, complete() = échange du
  code, access_token() = refresh automatique (60 s de marge, refresh_token
  conservé si absent de la réponse), api_get() = path absolu refusé + validation
  SSRF + borne 20 000 car.
- Tokens chiffrés Fernet en réutilisant calendar_sync._encrypt_tokens (zéro
  dépendance) dans la table connector_tokens (migration 33, PK (kind, user_id)).
- 4 routes /api/agent/connectors/oauth/{kind}/… : status, authorize (cookies
  d'état HttpOnly 10 min, retour same-origin validé), callback (GET safe, state
  comparé en temps constant, tokens stockés puis cookies purgés, redirection
  ?oauth=connected / ?oauth_error=), disconnect. OpenAPI 523 chemins.
- Config + .env.example : GOOGLE_CLIENT_ID/SECRET, MS_CLIENT_ID/SECRET (vidés =
  « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- Catalogue : google/ms365 en natifs avec badge connecté/non connecté ;
  connector_fetch et Tester passent par l'API du fournisseur avec le token de
  l'utilisateur (user_id transmis par l'outil LLM).
- Menu + : « Se connecter » / « Déconnecter » sur la fiche, toast au retour du
  flux (URL nettoyée par history.replaceState). État dans la fiche du menu
  plutôt qu'une page dédiée.
- Tests : tests/test_v756_oauth_connectors.py (13), 0 appel réseau réel
  (_post_form / _api_get monkeypatchés) — state forgé refusé sans échange,
  tokens chiffrés en base, refresh, URL absolue refusée, 401/404, câblage menu ;
  test_v755 adapté (5 natifs). Suite complète 1319 verts (-n auto), ruff 0,
  eslint 0 erreur (19 warnings préexistants hors fichiers touchés).
2026-10-07 08:29:22 -04:00

277 lines
12 KiB
Python

"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
3 connecteurs **natifs** (gitea, github, web) servis à la volée depuis la config,
plus des connecteurs **personnalisation** persistés (URL + clé) en base.
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
"""
from __future__ import annotations
import logging
from app.config import settings
from app.db import get_conn
from app.services import oauth_connectors
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
if kind in OAUTH_KINDS:
try:
oauth_connectors._client(kind) # lève si client_id/secret absents
except ValueError as exc:
return ("missing", str(exc))
tok = oauth_connectors.tokens(kind, user_id)
if tok.get("access_token"):
scope = (tok.get("scope") or "").split()
return ("ok", f"connecté · {len(scope)} scope(s)")
return ("missing", "non connecté — lancer la connexion OAuth")
if kind == "gitea":
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
if kind == "github":
if settings.github_token:
return ("ok", "PAT configuré (30 req/min)")
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
def list_connectors(user_id: int | None = None) -> list[dict]:
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
out: list[dict] = []
for kind in NATIVE_KINDS:
status, detail = native_state(kind, user_id)
out.append({
"id": None, "builtin": True, "kind": kind,
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
else kind.capitalize()),
"url": "", "enabled": True, "status": status, "detail": detail,
"has_secret": False, "oauth": kind in OAUTH_KINDS,
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors ORDER BY id"
).fetchall()
for r in rows:
out.append({
"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"]),
})
return out
def get(connector_id: int) -> dict | None:
with get_conn() as conn:
r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors WHERE id=?", (connector_id,)
).fetchone()
if not r:
return None
return {"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"])}
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "") -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
from app.services.importers.url_fetch import _validate_url
name = (name or "").strip()
if not name:
raise ValueError("name est requis")
url = _validate_url((url or "").strip()) # lève ValueError si hôte interne
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, detail) "
"VALUES (?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", ""),
)
conn.commit()
cid = cur.lastrowid
return get(cid)
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
row = get(connector_id)
if row is None:
return None
sets, params = [], []
if name is not None:
name = str(name).strip()
if not name:
raise ValueError("name est requis")
sets.append("name=?")
params.append(name)
if enabled is not None:
sets.append("enabled=?")
params.append(1 if enabled else 0)
if secret is not None:
sets.append("secret_encrypted=?")
params.append(encrypt_secret(str(secret)))
if sets:
with get_conn() as conn:
params.append(connector_id)
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return get(connector_id)
def delete_connector(connector_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
conn.commit()
return cur.rowcount > 0
# ── 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."""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret = ""
if connector_id:
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 "")
if secret:
headers["Authorization"] = f"Bearer {secret}"
headers["X-Api-Key"] = secret
return headers
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
"""GET gardé SSRF (validation + re-vérification après redirection).
Point d'injection des tests : on monkeypatche ``connectors._get``.
"""
from app.services.http_client import shared_client
from app.services.importers.url_fetch import _is_public_host, _validate_url
safe = _validate_url(url)
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
resp = await client.get(safe)
if resp.url.host and not _is_public_host(resp.url.host):
raise ValueError("Redirection vers un hôte non autorisé")
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
def _resolve(connector: str) -> tuple[str, str | int]:
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
token = str(connector or "").strip()
if not token:
raise ValueError("connector est requis")
if token.isdigit():
row = get(int(token))
if row is None:
raise ValueError(f"Connecteur inconnu: {token}")
return ("custom", row["id"])
low = token.lower()
if low in NATIVE_KINDS:
return (low, low)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
).fetchone()
if row:
return ("custom", row["id"])
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)."""
kind, ref = _resolve(connector)
try:
if kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id,
oauth_connectors.PROVIDERS[kind]["probe_path"])
status, detail = res["status"], res["text"][:200]
elif kind == "gitea":
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
{"Authorization": f"token {settings.gitea_token}"})
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
elif kind == "github":
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
elif kind == "web":
status, detail = "ok", native_state("web")[1]
else:
row = get(int(ref))
code, _ = await _get(row["url"], _headers(int(ref)))
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)
status, detail = "error", str(exc)[:200]
if kind == "custom":
with get_conn() as conn:
conn.execute(
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
(status, detail, int(ref)),
)
conn.commit()
return {"connector": connector, "status": status, "detail": detail}
def _gh_headers() -> dict:
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
"Accept": "application/vnd.github+json"}
if settings.github_token:
headers["Authorization"] = f"Bearer {settings.github_token}"
return headers
async def connector_fetch(connector: str, path: str = "", query: str = "",
user_id: int | None = None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector)
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind == "gitea":
base = settings.gitea_url.rstrip("/")
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
elif kind == "github":
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
code, text = await _get(url, _gh_headers())
elif kind == "web":
from app.services.web_search import search_web
if not (query or "").strip():
return {"status": "error", "text": "query est requis pour le connecteur web"}
results, provider = await search_web(query.strip(), 5)
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
for r in results) or "Aucun résultat."
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
else:
row = get(int(ref))
if row is None:
return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"}
url = row["url"].rstrip("/")
if path:
url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref)))
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:FETCH_LIMIT]}