[Coffre Secrets] Connecteurs par reference ObsiGate, isolation workspace (Phase 4) #26
@@ -28,6 +28,13 @@ OAUTH_REDIRECT_URI=
|
||||
# par release (passé par environment:/env_file: au conteneur).
|
||||
# Laisser vide = défaut officiel indiqué en commentaire.
|
||||
DRACO_SSO_URL= # ObsiGate, émettrice SSO — défaut https://og.dracodev.net ; dev : https://og-dev.dracodev.net
|
||||
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue ObsiGate #22) ──
|
||||
# OBSIGATE_COFFRE_URL vide = dérivée de DRACO_SSO_URL (même hôte).
|
||||
# OBSIGATE_CONSUMER_TOKEN = jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
|
||||
# (octrois) — distinct du jeton MCP de notes et de la session SSO.
|
||||
# Ne jamais le copier dans un log, un rapport ou une réponse API.
|
||||
OBSIGATE_COFFRE_URL=
|
||||
OBSIGATE_CONSUMER_TOKEN=
|
||||
DRACO_AGENT_URL= # Service d'agents Spark — défaut https://agent.dracodev.net ; dev : https://agent-dev.dracodev.net
|
||||
TIKTAK_URL= # TikTak (preset MCP, barre, refs) — défaut https://tiktak.dracodev.net ; dev : https://tiktak-dev.dracodev.net
|
||||
KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net
|
||||
|
||||
@@ -158,6 +158,13 @@ class Settings(BaseSettings):
|
||||
# KANBANTE_URL=https://kanbante-dev.dracodev.net
|
||||
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net
|
||||
draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO)
|
||||
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue #22) ──
|
||||
# URL de distribution : vide = dérivée de draco_sso_url (même hôte).
|
||||
# Jeton : le jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
|
||||
# (octrois), distinct du jeton MCP de notes et de la session SSO.
|
||||
# Jamais en clair dans un log, un rapport ou une réponse API.
|
||||
obsigate_coffre_url: str = ""
|
||||
obsigate_consumer_token: str = ""
|
||||
draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark)
|
||||
tiktak_url: str = "https://tiktak.dracodev.net"
|
||||
kanbante_url: str = "https://kanbante.dracodev.net"
|
||||
|
||||
@@ -2076,3 +2076,31 @@ def _migration_draco_links(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@register(52, "coffre ObsiGate (Phase 4) : workspace_id + coffre_reference sur agent_connectors")
|
||||
def _migration_coffre_reference(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 52 — références ObsiGate pour les connecteurs (issue #22).
|
||||
|
||||
* ``workspace_id`` : le connecteur appartient à un workspace (NULL =
|
||||
connecteur historique global, conservé tel quel — migration
|
||||
opportuniste, pas de big-bang).
|
||||
* ``coffre_reference`` : ``coffre:<slug>/<nom>`` (métadonnée, pas une
|
||||
valeur) ; vide = jeton chiffré local historique.
|
||||
Idempotente (gardée par ``columns()``), comme les migrations 32/34.
|
||||
"""
|
||||
cols = columns(conn, "agent_connectors")
|
||||
if "workspace_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE agent_connectors "
|
||||
"ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)"
|
||||
)
|
||||
if "coffre_reference" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE agent_connectors "
|
||||
"ADD COLUMN coffre_reference TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_connectors_workspace "
|
||||
"ON agent_connectors(workspace_id)"
|
||||
)
|
||||
|
||||
+10
-1
@@ -673,6 +673,8 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
|
||||
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
|
||||
kind=str(body.get("kind") or "custom"),
|
||||
auth=str(body.get("auth") or "bearer"),
|
||||
workspace_id=body.get("workspace_id"),
|
||||
coffre_reference=body.get("coffre_reference"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -682,11 +684,17 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
|
||||
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
|
||||
_current_user_id(request)
|
||||
try:
|
||||
kwargs = {}
|
||||
if "workspace_id" in body:
|
||||
kwargs["workspace_id"] = body.get("workspace_id")
|
||||
if "coffre_reference" in body:
|
||||
kwargs["coffre_reference"] = body.get("coffre_reference")
|
||||
row = connectors.update_connector(
|
||||
connector_id,
|
||||
name=body.get("name"),
|
||||
enabled=body.get("enabled"),
|
||||
secret=body.get("secret"),
|
||||
**kwargs,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -711,7 +719,8 @@ async def probe_connectors_route(request: Request, body: dict = Body(default={})
|
||||
raise HTTPException(status_code=400, detail="connector est requis")
|
||||
user_id = _current_user_id(request)
|
||||
try:
|
||||
return await connectors.probe(target, user_id)
|
||||
return await connectors.probe(target, user_id,
|
||||
workspace_id=body.get("workspace_id"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Références ObsiGate pour les connecteurs Flowdeck (Phase 4, issue ObsiGate #22).
|
||||
|
||||
Un connecteur d'un workspace ne stocke plus un jeton en clair ni même un
|
||||
jeton chiffré local : il stocke une **référence** ``coffre:<slug>/<nom>``,
|
||||
toujours liée à un ``workspace_id``. À l'appel, le serveur Flowdeck appelle
|
||||
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
|
||||
Flowdeck, utilise la valeur **en mémoire uniquement**, puis l'oublie :
|
||||
|
||||
* aucune valeur n'est persistée (ni en base, ni en fichier, ni en cache) ;
|
||||
* aucune valeur n'est journalisée (les logs ne portent que le statut) ;
|
||||
* si ObsiGate est indisponible, l'appel échoue proprement — il n'y a
|
||||
**aucun repli** sur une valeur en cache, puisqu'il n'y en a pas.
|
||||
|
||||
Format validé ici, aligné sur ObsiGate (``backend/coffre_store.py``) :
|
||||
``slug`` et ``nom`` en minuscules, 3 à 64 / 3 à 128 caractères,
|
||||
``[a-z0-9_-]`` après le premier caractère. La référence elle-même n'est
|
||||
pas un secret (c'est une métadonnée, comme un chemin) : seule la valeur
|
||||
distribuée l'est, et elle ne transite que dans la réponse d'ObsiGate.
|
||||
|
||||
Isolation workspace (critère central) : l'octroi ObsiGate est accordé au
|
||||
**consommateur Flowdeck** (un jeton par application), pas à un workspace.
|
||||
L'isolation par workspace est donc appliquée **côté Flowdeck, avant tout
|
||||
appel** — voir ``connectors.resolve_secret`` : un connecteur dont le
|
||||
``workspace_id`` diffère du workspace demandeur est refusé explicitement
|
||||
et le distributeur n'est jamais appelé. Une isolation appliquée côté
|
||||
ObsiGate exigerait un consommateur par workspace (reste à faire, voir
|
||||
``docs/coffre-reference-flowdeck.md``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Contexte envoyé à ObsiGate : le workspace Flowdeck demandeur, pour que
|
||||
#: la distribution soit traçable côté ObsiGate (journal des distributions).
|
||||
CONTEXTE_TYPE = "flowdeck_workspace"
|
||||
|
||||
#: Regex ObsiGate, recopiées à l'identique (slug 3–64, nom 3–128).
|
||||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
|
||||
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
|
||||
|
||||
#: Délai maximal d'une distribution (secondes) — un coffre injoignable ne
|
||||
#: doit pas bloquer indéfiniment un appel de connecteur.
|
||||
TIMEOUT_S = 10
|
||||
|
||||
|
||||
class CoffreReferenceInvalide(ValueError):
|
||||
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
|
||||
|
||||
|
||||
class CoffreWorkspaceMismatch(ValueError):
|
||||
"""Le connecteur appartient à un autre workspace que le demandeur."""
|
||||
|
||||
|
||||
class CoffreAccesRefuse(ValueError):
|
||||
"""ObsiGate a refusé la distribution (octroi absent, révoqué, expiré…).
|
||||
|
||||
``detail`` reprend le code ObsiGate (``octroi_revoque``, …) sans
|
||||
jamais contenir la valeur du secret.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, detail: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class CoffreIndisponible(ValueError):
|
||||
"""ObsiGate est injoignable ou en erreur technique (pas de repli)."""
|
||||
|
||||
|
||||
def parse_reference(reference: str) -> tuple[str, str]:
|
||||
"""Valide ``coffre:<slug>/<nom>`` et renvoie ``(slug, nom)`` normalisés.
|
||||
|
||||
La casse est ramenée en minuscules, comme côté ObsiGate
|
||||
(``valider_slug`` / ``valider_nom_secret``).
|
||||
"""
|
||||
value = (reference or "").strip().lower()
|
||||
if not value.startswith("coffre:") or "/" not in value[7:]:
|
||||
raise CoffreReferenceInvalide(
|
||||
"référence invalide : format attendu coffre:<slug>/<nom>"
|
||||
)
|
||||
slug, nom = value[7:].split("/", 1)
|
||||
if not SLUG_RE.match(slug):
|
||||
raise CoffreReferenceInvalide(
|
||||
"slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
|
||||
)
|
||||
if not NOM_SECRET_RE.match(nom):
|
||||
raise CoffreReferenceInvalide(
|
||||
"nom de secret invalide : 3 à 128 caractères, minuscules, "
|
||||
"chiffres, - ou _"
|
||||
)
|
||||
return slug, nom
|
||||
|
||||
|
||||
def is_reference(value: str | None) -> bool:
|
||||
"""Vrai si la valeur ressemble à une référence (sans lever d'erreur)."""
|
||||
try:
|
||||
parse_reference(value or "")
|
||||
except CoffreReferenceInvalide:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _config() -> tuple[str, str]:
|
||||
"""(base ObsiGate, jeton consommateur) depuis la configuration.
|
||||
|
||||
L'URL explicite ``obsigate_coffre_url`` prime ; sinon on dérive de
|
||||
``draco_sso_url`` (ObsiGate, émettrice SSO — même hôte). Le jeton est
|
||||
le jeton **consommateur** Flowdeck enregistré dans ObsiGate, distinct
|
||||
du jeton MCP de notes et de la session SSO.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
base = (getattr(settings, "obsigate_coffre_url", "") or "").strip()
|
||||
if not base:
|
||||
base = (settings.draco_sso_url or "").strip()
|
||||
token = (getattr(settings, "obsigate_consumer_token", "") or "").strip()
|
||||
return base.rstrip("/"), token
|
||||
|
||||
|
||||
def distribuer(reference: str, workspace_id: int, *, base_url: str | None = None,
|
||||
consumer_token: str | None = None, request=None) -> str:
|
||||
"""Distribue une valeur ObsiGate **en mémoire** pour un workspace.
|
||||
|
||||
``request`` est le point d'injection des tests : un appelable
|
||||
``(url, payload, headers) -> (status, body_dict)`` qui remplace
|
||||
l'appel HTTP réel (faux endpoint de distribution, clairement
|
||||
identifié comme tel dans les tests). Sans lui, l'appel HTTP réel
|
||||
est effectué via la bibliothèque standard (aucune dépendance).
|
||||
|
||||
Ne conserve rien : la valeur renvoyée doit être utilisée puis
|
||||
oubliée par l'appelant, jamais écrite ni mise en cache.
|
||||
"""
|
||||
slug, nom = parse_reference(reference)
|
||||
if not workspace_id or int(workspace_id) <= 0:
|
||||
raise ValueError("workspace_id est requis pour distribuer une référence")
|
||||
workspace_id = int(workspace_id)
|
||||
base, token = _config()
|
||||
if base_url is not None:
|
||||
base = base_url.rstrip("/")
|
||||
if consumer_token is not None:
|
||||
token = consumer_token
|
||||
if not base:
|
||||
raise CoffreIndisponible("URL ObsiGate non configurée (obsigate_coffre_url)")
|
||||
if not token:
|
||||
raise CoffreIndisponible(
|
||||
"jeton consommateur ObsiGate non configuré (obsigate_consumer_token)"
|
||||
)
|
||||
url = f"{base}/api/coffres/distribuer"
|
||||
payload = {
|
||||
"reference": f"coffre:{slug}/{nom}",
|
||||
"contexte_type": CONTEXTE_TYPE,
|
||||
"contexte_id": str(workspace_id),
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
try:
|
||||
if request is not None:
|
||||
status, body = request(url, payload, headers)
|
||||
else:
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(payload).encode(), headers=headers, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
|
||||
status, body = resp.status, json.loads(resp.read() or b"{}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read() or b"{}")
|
||||
except (ValueError, TypeError):
|
||||
body = {}
|
||||
status = exc.code
|
||||
except CoffreAccesRefuse:
|
||||
raise
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
|
||||
# ObsiGate injoignable : échec propre, SANS repli sur un cache.
|
||||
logger.warning("Distribution ObsiGate injoignable (statut seul journalisé)")
|
||||
raise CoffreIndisponible(
|
||||
"ObsiGate est indisponible : distribution impossible, "
|
||||
"aucune valeur en cache n'est utilisée"
|
||||
) from exc
|
||||
if status == 200:
|
||||
valeur = (body or {}).get("valeur") or ""
|
||||
if not valeur:
|
||||
raise CoffreIndisponible("ObsiGate a répondu sans valeur")
|
||||
return valeur
|
||||
detail = str((body or {}).get("detail") or "")
|
||||
if status in (401, 403):
|
||||
if detail in ("octroi_revoque", "consommateur_revoque"):
|
||||
message = ("accès révoqué, à ré-accorder dans ObsiGate "
|
||||
"(l'octroi de cette référence a été révoqué)")
|
||||
elif detail == "octroi_expire":
|
||||
message = "accès expiré, à ré-accorder dans ObsiGate"
|
||||
else:
|
||||
message = "accès refusé par ObsiGate (octroi absent ou invalide)"
|
||||
raise CoffreAccesRefuse(message, detail=detail)
|
||||
if status == 410:
|
||||
raise CoffreAccesRefuse("secret expiré côté ObsiGate", detail="secret_expire")
|
||||
if status in (400,):
|
||||
raise CoffreReferenceInvalide(f"référence rejetée par ObsiGate ({detail})")
|
||||
raise CoffreIndisponible(f"ObsiGate a répondu HTTP {status} (distribution impossible)")
|
||||
+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]}
|
||||
|
||||
@@ -189,7 +189,8 @@ async def _handshake(url: str, headers: dict | None) -> dict:
|
||||
return session
|
||||
|
||||
|
||||
def _server(connector_id: int) -> dict:
|
||||
def _server(connector_id: int, *, workspace_id: int | None = None,
|
||||
distribute=None) -> dict:
|
||||
row = get(connector_id)
|
||||
if row is None:
|
||||
raise ValueError("Serveur MCP introuvable")
|
||||
@@ -198,7 +199,8 @@ def _server(connector_id: int) -> dict:
|
||||
if not row["enabled"]:
|
||||
raise ValueError("Serveur MCP désactivé")
|
||||
return {"id": row["id"], "name": row["name"], "url": row["url"],
|
||||
"headers": _headers(connector_id)}
|
||||
"headers": _headers(connector_id, workspace_id=workspace_id,
|
||||
distribute=distribute)}
|
||||
|
||||
|
||||
def _normalize(server: dict, raw: list) -> list[dict]:
|
||||
@@ -226,10 +228,16 @@ def _save_tools(connector_id: int, tools: list[dict]) -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def initialize_and_list(connector_id: int) -> list[dict]:
|
||||
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
async def initialize_and_list(connector_id: int, *, workspace_id: int | None = None,
|
||||
distribute=None) -> list[dict]:
|
||||
"""Handshake + ``tools/list`` → met à jour le cache de la base.
|
||||
|
||||
Pour un serveur à référence ObsiGate, ``workspace_id`` est obligatoire
|
||||
(isolation vérifiée avant distribution, voir ``connectors``).
|
||||
"""
|
||||
srv = _server(connector_id, workspace_id=workspace_id, distribute=distribute)
|
||||
url = _with_secret(srv["url"], connector_id, workspace_id=workspace_id,
|
||||
distribute=distribute)
|
||||
session = await _handshake(url, srv["headers"])
|
||||
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||
"params": {}}, session)
|
||||
@@ -276,14 +284,16 @@ def _content_text(result: dict) -> str:
|
||||
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
|
||||
|
||||
|
||||
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
|
||||
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None, *,
|
||||
workspace_id: int | None = None, distribute=None) -> dict:
|
||||
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.).
|
||||
|
||||
Le handshake (avec session éventuelle) précède systématiquement
|
||||
l'appel : c'est la condition T0 des serveurs à session.
|
||||
"""
|
||||
srv = _server(connector_id)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
srv = _server(connector_id, workspace_id=workspace_id, distribute=distribute)
|
||||
url = _with_secret(srv["url"], connector_id, workspace_id=workspace_id,
|
||||
distribute=distribute)
|
||||
session = await _handshake(url, srv["headers"])
|
||||
data = await _rpc(url, {
|
||||
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Flowdeck — Connecteurs à référence ObsiGate (Phase 4, issue ObsiGate #22)
|
||||
|
||||
## Principe
|
||||
|
||||
Un connecteur personnel d'un workspace peut pointer vers une référence
|
||||
ObsiGate au lieu d'un jeton :
|
||||
|
||||
```
|
||||
coffre:<slug>/<nom> ex. coffre:flowdeck/gitea-lecture
|
||||
```
|
||||
|
||||
* Le champ jeton du connecteur **devient** le champ référence : aucun
|
||||
jeton n'est stocké pour ce connecteur (`secret_encrypted` reste vide).
|
||||
* L'octroi est **toujours lié à un `workspace_id`** : créer un connecteur
|
||||
à référence sans workspace est refusé (HTTP 400 / `ValueError`).
|
||||
* À l'appel, le **serveur** Flowdeck appelle
|
||||
`POST /api/coffres/distribuer` d'ObsiGate avec le jeton consommateur
|
||||
Flowdeck et un contexte `flowdeck_workspace / <workspace_id>`, utilise
|
||||
la valeur **en mémoire**, puis l'oublie. La valeur n'est jamais
|
||||
persistée, jamais mise en cache, jamais journalisée, jamais renvoyée
|
||||
à une réponse API ni au LLM.
|
||||
|
||||
## Configuration (serveur Flowdeck)
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
| --- | --- | --- |
|
||||
| `OBSIGATE_COFFRE_URL` | dérivée de `DRACO_SSO_URL` | Base ObsiGate pour `/api/coffres/distribuer` |
|
||||
| `OBSIGATE_CONSUMER_TOKEN` | (vide = distribution désactivée) | Jeton **consommateur** Flowdeck enregistré dans ObsiGate |
|
||||
|
||||
Le jeton consommateur est distinct du jeton MCP de notes et de la
|
||||
session SSO. Il ne figure dans aucun log, rapport ou réponse.
|
||||
|
||||
## Isolation workspace — ce qui est garanti, et sa limite honnête
|
||||
|
||||
**Garanti côté Flowdeck, avant tout appel** (`connectors.resolve_secret`) :
|
||||
|
||||
* un connecteur à référence ne se résout que pour son propre
|
||||
`workspace_id` ;
|
||||
* workspace A tentant d'utiliser le connecteur (donc la référence /
|
||||
l'octroi) du workspace B reçoit un refus explicite
|
||||
(`CoffreWorkspaceMismatch` : « … ne peut pas utiliser ce connecteur
|
||||
(il appartient au workspace …) : octroi d'un autre workspace refusé »)
|
||||
et **le distributeur n'est pas appelé** ;
|
||||
* un `workspace_id` déjà posé sur un connecteur est immuable : on ne
|
||||
transfère pas un octroi en éditant le connecteur ;
|
||||
* un connecteur à référence testé/appelé sans `workspace_id` échoue
|
||||
proprement, jamais avec la valeur d'un autre workspace.
|
||||
|
||||
**Limite à connaître** : côté ObsiGate, l'octroi est accordé au
|
||||
consommateur **Flowdeck** (un jeton par application), pas à un workspace
|
||||
— le `contexte_id` transmis sert à la traçabilité, pas à un contrôle
|
||||
d'accès côté ObsiGate. L'isolation par workspace repose donc sur le
|
||||
contrôle Flowdeck ci-dessus. Une isolation appliquée côté ObsiGate
|
||||
exigerait un consommateur par workspace : c'est un reste à faire
|
||||
décidé côté ObsiGate (enregistrement de consommateurs par workspace),
|
||||
pas un prérequis pour utiliser ce mécanisme.
|
||||
|
||||
## Échecs propres, sans repli
|
||||
|
||||
| Situation | Comportement Flowdeck |
|
||||
| --- | --- |
|
||||
| ObsiGate injoignable / timeout / HTTP 5xx | `CoffreIndisponible` — l'appel échoue, **aucune** valeur en cache n'est utilisée (il n'y en a pas) |
|
||||
| Octroi révoqué / consommateur révoqué (403) | `CoffreAccesRefuse` — message « accès révoqué, à ré-accorder dans ObsiGate » |
|
||||
| Octroi expiré (403) / secret expiré (410) | `CoffreAccesRefuse` avec le code ObsiGate, sans la valeur |
|
||||
| Référence mal formée | `CoffreReferenceInvalide` (format `coffre:<slug>/<nom>`, slug 3–64, nom 3–128, minuscules, chiffres, `-`/`_`, aligné sur ObsiGate) |
|
||||
|
||||
## Secrets déjà chiffrés : pas de big-bang
|
||||
|
||||
Les connecteurs historiques (`secret_encrypted` Fernet) restent valides
|
||||
tels quels. La migration est **opportuniste, connecteur par
|
||||
connecteur** :
|
||||
|
||||
1. (optionnel) rattacher d'abord le connecteur à son workspace
|
||||
(`PATCH /api/agent/connectors/{id}` avec `workspace_id`) ;
|
||||
2. puis basculer vers la référence (`PATCH …` avec
|
||||
`coffre_reference: "coffre:<slug>/<nom>"`) — le jeton local dormant
|
||||
n'est pas effacé automatiquement, il est simplement ignoré tant que
|
||||
la référence est posée ; effacer la référence (`""`) le réactive ;
|
||||
3. vérifier par un probe avec `workspace_id`, puis supprimer le jeton
|
||||
local si besoin (rotation côté ObsiGate).
|
||||
|
||||
## Chemin de migration par connecteur
|
||||
|
||||
| Connecteur actuel | État dans cette branche | Chemin |
|
||||
| --- | --- | --- |
|
||||
| Personnels (`custom`, `discord`, `telegram`, `mcp`, `tiktak`, `obsigate`) | **Implémenté** : `workspace_id` + `coffre_reference`, résolution en mémoire, isolation testée | Basculer connecteur par connecteur (ci-dessus) |
|
||||
| Natifs Gitea / GitHub | **Inchangés** : jetons globaux d'instance (`GITEA_TOKEN`, `GITHUB_TOKEN` en `.env`) | Créer un connecteur personnel workspace-scopé à référence vers le même service et l'utiliser à la place du natif, quand le besoin workspace par workspace se présente ; les natifs ne sont pas supprimés (pas de big-bang) |
|
||||
| LLM global (`llm_config` id=1) et clés par utilisateur (`user_llm_keys`) | **Inchangés** : clés en clair en base (globale / par utilisateur, pas par workspace) | Travail ultérieur dédié : ajouter une colonne référence sur `user_llm_keys` + résolution par workspace, avec le même patron (distribution en mémoire, workspace obligatoire) ; hors périmètre de cette branche |
|
||||
| OAuth Google / M365 (`connector_tokens`) | **Inchangés** : jetons OAuth chiffrés par (kind, utilisateur) | Hors périmètre : le flux OAuth ne se prête pas à une référence coffre sans repenser le refresh ; à traiter séparément si besoin |
|
||||
|
||||
## API
|
||||
|
||||
* `POST /api/agent/connectors` accepte `workspace_id` et
|
||||
`coffre_reference` (référence sans workspace → 400).
|
||||
* `PATCH /api/agent/connectors/{id}` accepte `workspace_id`
|
||||
(premier rattachement seulement) et `coffre_reference` (`""` =
|
||||
effacer la référence).
|
||||
* `POST /api/agent/connectors/probe` accepte `workspace_id`.
|
||||
* Réponses : `workspace_id`, `coffre_reference`, `secret_source`
|
||||
(`coffre` | `chiffre_local` | `aucun`) — jamais de valeur.
|
||||
* `GET /api/agent/connectors` liste les connecteurs avec ces champs.
|
||||
|
||||
## Ce qui reste à faire (validation réelle)
|
||||
|
||||
* Valider contre une vraie instance ObsiGate : créer le consommateur
|
||||
Flowdeck + les octrois, puis un probe réel avec `workspace_id`
|
||||
(les tests ici utilisent un faux endpoint de distribution).
|
||||
* Threader `workspace_id` depuis l'agent : `AgentEngine.workspace_id`
|
||||
→ `connector_fetch` (outil LLM) et `McpTool.call_tool` — aujourd'hui
|
||||
ces chemins appellent sans workspace et échouent proprement pour les
|
||||
connecteurs à référence (refus, pas de fuite) ; les connecteurs
|
||||
historiques ne sont pas affectés.
|
||||
* Le cas échéant, consommateur ObsiGate par workspace (voir limite
|
||||
ci-dessus) et bascule des natifs Gitea/GitHub et des clés LLM
|
||||
(tableau ci-dessus).
|
||||
* Interface : sélecteur de référence dans le formulaire connecteur
|
||||
(aujourd'hui, champ `coffre_reference` via l'API seulement).
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Phase 4 (issue ObsiGate #22) — connecteurs Flowdeck à référence ObsiGate.
|
||||
|
||||
Couverture : format de référence, création liée à un workspace, isolation
|
||||
(A ne distribue pas avec l'octroi de B — le faux distributeur n'est même
|
||||
pas appelé), distribution en mémoire sans persistance ni cache, octroi
|
||||
révoqué, ObsiGate indisponible (aucun repli), connecteurs historiques
|
||||
chiffrés toujours valides, routes API.
|
||||
|
||||
Le distributeur est un FAUX endpoint (appelable injecté), clairement
|
||||
identifié comme tel : aucun test ne parle à une vraie instance ObsiGate.
|
||||
Les valeurs de test sont fictives (``TEST-VALUE-XXXX``).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import coffre_reference, connectors
|
||||
from app.services.coffre_reference import (
|
||||
CoffreAccesRefuse,
|
||||
CoffreIndisponible,
|
||||
CoffreReferenceInvalide,
|
||||
CoffreWorkspaceMismatch,
|
||||
)
|
||||
|
||||
PUBLIC_URL = "https://example.com/api"
|
||||
REFERENCE = "coffre:flowdeck/gitea-lecture"
|
||||
FAKE_VALUE = "TEST-VALUE-XXXX"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _faux_jeton_consommateur():
|
||||
"""Le distributeur exige un jeton consommateur : valeur fictive de test.
|
||||
|
||||
Sans elle, ``distribuer`` refuserait avant même d'appeler le faux
|
||||
endpoint (comportement voulu en production).
|
||||
"""
|
||||
import app.config
|
||||
|
||||
app.config.settings.obsigate_consumer_token = "TEST-CONSUMER-TOKEN-XXXX"
|
||||
yield
|
||||
|
||||
|
||||
def _workspaces():
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) "
|
||||
"VALUES (1, 'tester', 'Tester', 1)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier A', 1)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier B', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM workspaces ORDER BY id"
|
||||
).fetchall()
|
||||
return int(rows[0]["id"]), int(rows[1]["id"])
|
||||
|
||||
|
||||
class _FakeDistributeur:
|
||||
"""Faux endpoint ObsiGate : compte les appels, ne persiste rien."""
|
||||
|
||||
def __init__(self, behaviour="ok"):
|
||||
self.behaviour = behaviour
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, url, payload, headers):
|
||||
self.calls.append((url, dict(payload)))
|
||||
assert url.endswith("/api/coffres/distribuer"), url
|
||||
assert headers.get("Authorization", "").startswith("Bearer "), headers
|
||||
if self.behaviour == "ok":
|
||||
return 200, {"reference": payload["reference"], "valeur": FAKE_VALUE,
|
||||
"version": 1}
|
||||
if self.behaviour == "revoque":
|
||||
return 403, {"detail": "octroi_revoque"}
|
||||
if self.behaviour == "indisponible":
|
||||
raise ConnectionError("faux ObsiGate injoignable")
|
||||
raise AssertionError(self.behaviour)
|
||||
|
||||
|
||||
def _create_coffre(client, ws, **kw):
|
||||
body = {"name": "Conn coffre", "url": PUBLIC_URL,
|
||||
"workspace_id": ws, "coffre_reference": REFERENCE, **kw}
|
||||
r = client.post("/api/agent/connectors", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ── Format ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"gitea-lecture", "coffre:", "coffre:slug-seul", "coffre:AB/mauvais",
|
||||
"coffre:a/b", "coffre:ok/mauvais nom", "coffre:ok/",
|
||||
])
|
||||
def test_reference_invalide_rejetee(bad):
|
||||
with pytest.raises(CoffreReferenceInvalide):
|
||||
coffre_reference.parse_reference(bad)
|
||||
|
||||
|
||||
def test_reference_normalisee_minuscules():
|
||||
assert coffre_reference.parse_reference("Coffre:FlowDeck/Gitea-Lecture") == (
|
||||
"flowdeck", "gitea-lecture")
|
||||
|
||||
|
||||
# ── Création liée au workspace ─────────────────────────────────────────────
|
||||
|
||||
def test_creation_coffre_exige_workspace(client):
|
||||
r = client.post("/api/agent/connectors",
|
||||
json={"name": "Sans WS", "url": PUBLIC_URL,
|
||||
"coffre_reference": REFERENCE})
|
||||
assert r.status_code == 400, r.text
|
||||
assert "workspace" in r.text.lower()
|
||||
|
||||
|
||||
def test_creation_coffre_refuse_secret_et_reference(client):
|
||||
ws_a, _ = _workspaces()
|
||||
r = client.post("/api/agent/connectors",
|
||||
json={"name": "Ambigu", "url": PUBLIC_URL, "secret": "x",
|
||||
"workspace_id": ws_a, "coffre_reference": REFERENCE})
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_creation_coffre_ne_stocke_aucun_jeton(client):
|
||||
ws_a, _ = _workspaces()
|
||||
row = _create_coffre(client, ws_a)
|
||||
assert row["coffre_reference"] == REFERENCE
|
||||
assert row["workspace_id"] == ws_a
|
||||
assert row["secret_source"] == "coffre"
|
||||
assert row["has_secret"] is True
|
||||
assert FAKE_VALUE not in str(row)
|
||||
with get_conn() as conn:
|
||||
stored = conn.execute(
|
||||
"SELECT secret_encrypted, coffre_reference FROM agent_connectors "
|
||||
"WHERE id=?", (row["id"],)
|
||||
).fetchone()
|
||||
assert stored["secret_encrypted"] == "" # aucun jeton stocké
|
||||
assert stored["coffre_reference"] == REFERENCE # métadonnée seulement
|
||||
|
||||
|
||||
def test_creation_workspace_introuvable(client):
|
||||
r = client.post("/api/agent/connectors",
|
||||
json={"name": "WS ?", "url": PUBLIC_URL,
|
||||
"workspace_id": 999999, "coffre_reference": REFERENCE})
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
# ── Résolution + isolation (critère central) ───────────────────────────────
|
||||
|
||||
def test_resolve_bon_workspace_distribue_en_memoire(client):
|
||||
ws_a, _ = _workspaces()
|
||||
row = _create_coffre(client, ws_a)
|
||||
fake = _FakeDistributeur()
|
||||
valeur = connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||
distribute=fake)
|
||||
assert valeur == FAKE_VALUE
|
||||
assert len(fake.calls) == 1
|
||||
_, payload = fake.calls[0]
|
||||
assert payload["reference"] == REFERENCE
|
||||
assert payload["contexte_type"] == "flowdeck_workspace"
|
||||
assert payload["contexte_id"] == str(ws_a)
|
||||
# Rien n'a été persisté ni mis en cache par la distribution.
|
||||
with get_conn() as conn:
|
||||
stored = conn.execute(
|
||||
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
|
||||
(row["id"],)
|
||||
).fetchone()
|
||||
assert stored["secret_encrypted"] == ""
|
||||
assert FAKE_VALUE not in str(connectors.get(row["id"]))
|
||||
|
||||
|
||||
def test_isolation_workspace_a_ne_distribue_pas_octroi_de_b(client):
|
||||
ws_a, ws_b = _workspaces()
|
||||
row = _create_coffre(client, ws_b) # octroi du workspace B
|
||||
fake = _FakeDistributeur()
|
||||
with pytest.raises(CoffreWorkspaceMismatch) as exc:
|
||||
connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||
distribute=fake)
|
||||
assert "ne peut pas utiliser" in str(exc.value)
|
||||
assert fake.calls == [] # distributeur jamais appelé
|
||||
|
||||
|
||||
def test_resolve_sans_workspace_refuse(client):
|
||||
ws_a, _ = _workspaces()
|
||||
row = _create_coffre(client, ws_a)
|
||||
fake = _FakeDistributeur()
|
||||
with pytest.raises(ValueError, match="workspace_id"):
|
||||
connectors.resolve_secret(row["id"], distribute=fake)
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
def test_headers_bon_workspace_mauvais_workspace(client):
|
||||
ws_a, ws_b = _workspaces()
|
||||
row = _create_coffre(client, ws_a)
|
||||
fake = _FakeDistributeur()
|
||||
headers = connectors._headers(row["id"], workspace_id=ws_a,
|
||||
distribute=fake)
|
||||
assert headers["Authorization"] == f"Bearer {FAKE_VALUE}"
|
||||
with pytest.raises(CoffreWorkspaceMismatch):
|
||||
connectors._headers(row["id"], workspace_id=ws_b, distribute=fake)
|
||||
|
||||
|
||||
# ── Échecs propres ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_octroi_revoque_message_explicite_sans_repli(client):
|
||||
ws_a, _ = _workspaces()
|
||||
row = _create_coffre(client, ws_a)
|
||||
fake = _FakeDistributeur(behaviour="revoque")
|
||||
with pytest.raises(CoffreAccesRefuse) as exc:
|
||||
connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||
distribute=fake)
|
||||
assert "révoqué" in str(exc.value)
|
||||
assert "ré-accorder" in str(exc.value)
|
||||
assert exc.value.detail == "octroi_revoque"
|
||||
|
||||
|
||||
def test_obsigate_indisponible_sans_valeur_en_cache(client):
|
||||
ws_a, _ = _workspaces()
|
||||
row = _create_coffre(client, ws_a)
|
||||
fake = _FakeDistributeur(behaviour="indisponible")
|
||||
for _ in range(2): # deux appels : aucun ne doit renvoyer une valeur
|
||||
with pytest.raises(CoffreIndisponible):
|
||||
connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||
distribute=fake)
|
||||
assert len(fake.calls) == 2 # chaque appel retente, rien en cache
|
||||
|
||||
|
||||
# ── Migration opportuniste ─────────────────────────────────────────────────
|
||||
|
||||
def test_connecteur_historique_chiffre_toujours_valide(client):
|
||||
body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc"}
|
||||
row = client.post("/api/agent/connectors", json=body).json()
|
||||
assert row["secret_source"] == "chiffre_local"
|
||||
assert connectors.resolve_secret(row["id"]) == "sk-live-abc"
|
||||
|
||||
|
||||
def test_bascule_patch_vers_reference_puis_retour(client):
|
||||
ws_a, ws_b = _workspaces()
|
||||
row = client.post("/api/agent/connectors",
|
||||
json={"name": "Migrable", "url": PUBLIC_URL,
|
||||
"secret": "sk-live-abc"}).json()
|
||||
cid = row["id"]
|
||||
# 1. rattacher au workspace, 2. basculer vers la référence.
|
||||
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||
json={"workspace_id": ws_a})
|
||||
assert r.status_code == 200, r.text
|
||||
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||
json={"coffre_reference": REFERENCE})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["secret_source"] == "coffre"
|
||||
# Le workspace est immuable : pas de transfert d'octroi vers B.
|
||||
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||
json={"workspace_id": ws_b})
|
||||
assert r.status_code == 400, r.text
|
||||
# Effacer la référence réactive le jeton local dormant.
|
||||
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||
json={"coffre_reference": ""})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["secret_source"] == "chiffre_local"
|
||||
assert connectors.resolve_secret(cid) == "sk-live-abc"
|
||||
|
||||
|
||||
def test_probe_coffre_sans_workspace_echoue_proprement(client, monkeypatch):
|
||||
ws_a, _ = _workspaces()
|
||||
row = _create_coffre(client, ws_a)
|
||||
|
||||
async def ok_get(url, headers=None):
|
||||
return 200, "{}"
|
||||
|
||||
monkeypatch.setattr(connectors, "_get", ok_get)
|
||||
r = client.post("/api/agent/connectors/probe",
|
||||
json={"connector": str(row["id"])})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "error" # refus, pas de fuite inter-workspace
|
||||
|
||||
|
||||
def test_routes_exigent_session(client):
|
||||
anon_csrf(client)
|
||||
assert client.post("/api/agent/connectors",
|
||||
json={"name": "x", "url": PUBLIC_URL}).status_code == 401
|
||||
Reference in New Issue
Block a user