[Coffre Secrets] Connecteurs par reference ObsiGate, isolation workspace (Phase 4) #26
@@ -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)")
|
||||
Reference in New Issue
Block a user