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