coffre ObsiGate Phase 4 (issue #22) : app/services/coffre_reference.py
This commit is contained in:
@@ -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