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