coffre ObsiGate Phase 4 (issue #22) : app/services/connectors.py
This commit is contained in:
+244
-38
@@ -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:<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,
|
||||
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]}
|
||||
|
||||
Reference in New Issue
Block a user