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