Files
flowdeck/app/services/coffre_reference.py
T
bruno 417c0a23d7
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Failing after 3h10m16s
FlowDeck CI / docker (push) Skipped
coffre ObsiGate Phase 4 (issue #22) : app/services/coffre_reference.py
2026-10-27 23:47:54 -04:00

207 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)")