"""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)")