207 lines
8.5 KiB
Python
207 lines
8.5 KiB
Python
"""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)")
|