147 lines
5.6 KiB
Python
147 lines
5.6 KiB
Python
"""
|
|
AUTH.3A — tolaria_client.py
|
|
============================
|
|
Isolierter Tolaria-Client für den SAVE-Executor (SAVE-only).
|
|
|
|
Eigenschaften:
|
|
* FIXED Base URL (aus ENV C5_TOLARIA_BASE oder Default http://tolaria:5173/api/vault)
|
|
— NIE aus dem Job. Keine arbitrary URL.
|
|
* SAVE Endpoint fest: /save. Read-Back fest: /content.
|
|
* Keine arbitrary headers/method.
|
|
* Fail-closed: kein HTTP ohne SAVE-Credential (TOLARIA_SAVE_TOKEN).
|
|
* OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry).
|
|
* Token-Wert wird NIE geloggt.
|
|
|
|
Nur SAVE-Scope. Kein DELETE. Kein Master-Token.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any, Dict, Optional
|
|
|
|
# FIXED Tolaria Base URL (produktiv: interne Docker-DNS-Adresse)
|
|
DEFAULT_TOLARIA_BASE = "http://tolaria:5173/api/vault"
|
|
ENV_TOLARIA_BASE = "C5_TOLARIA_BASE"
|
|
ENV_TOLARIA_SAVE_TOKEN = "TOLARIA_SAVE_TOKEN"
|
|
|
|
# Feste Endpoints (keine arbitrary URL/method)
|
|
ENDPOINT_SAVE = "save"
|
|
ENDPOINT_CONTENT = "content"
|
|
|
|
# Eindeutige, enge Not-Found-Semantik der Tolaria-Vault-API
|
|
TOLARIA_NOT_FOUND_MSG = "Invalid or missing path"
|
|
|
|
|
|
class TolariaClientError(Exception):
|
|
pass
|
|
|
|
|
|
class TolariaUnavailableError(TolariaClientError):
|
|
pass
|
|
|
|
|
|
class TolariaWriteError(TolariaClientError):
|
|
def __init__(self, message: str, code: str, http_code: Optional[int] = None):
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.http_code = http_code
|
|
|
|
|
|
class TolariaClient:
|
|
"""
|
|
Isolierter Tolaria-Client (SAVE-only).
|
|
|
|
read() — POST /content (Read-Back)
|
|
write() — POST /save (NUR SAVE; fail-closed ohne Credential)
|
|
"""
|
|
|
|
def __init__(self, base_url: Optional[str] = None, timeout: float = 15.0,
|
|
save_token: Optional[str] = None):
|
|
# FIXED Base URL: aus ENV oder Default. NIE aus dem Job.
|
|
self.base_url = (base_url or os.environ.get(ENV_TOLARIA_BASE)
|
|
or DEFAULT_TOLARIA_BASE).rstrip("/")
|
|
self.timeout = timeout
|
|
# SAVE-Credential explizit injiziert (kein verstecktes globales).
|
|
# Fehlend/leer -> fail-closed beim mutierenden Aufruf.
|
|
self.save_token = save_token
|
|
|
|
# -- HTTP-Helfer --------------------------------------------------------
|
|
|
|
def _post(self, endpoint: str, payload: Dict[str, Any],
|
|
auth_token: Optional[str] = None) -> Dict[str, Any]:
|
|
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
# Authorization-Header NUR wenn ein Token explizit übergeben wird
|
|
# (mutierender SAVE). READ sendet KEIN Credential (Least Privilege).
|
|
if auth_token is not None:
|
|
headers["Authorization"] = f"Bearer {auth_token}"
|
|
req = urllib.request.Request(url, data=data, headers=headers,
|
|
method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
body = resp.read().decode("utf-8")
|
|
return json.loads(body) if body else {}
|
|
except urllib.error.HTTPError as e:
|
|
if e.code >= 500:
|
|
raise TolariaUnavailableError(
|
|
f"Tolaria HTTP {e.code} auf {endpoint}",
|
|
"TOLARIA_UNAVAILABLE")
|
|
raise TolariaWriteError(
|
|
f"Tolaria HTTP {e.code} auf {endpoint}: "
|
|
f"{e.read().decode('utf-8', 'replace')[:200]}",
|
|
"AUTH_FAILURE" if e.code in (401, 403) else "INVALID_SCHEMA",
|
|
http_code=e.code)
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
raise TolariaUnavailableError(
|
|
f"Tolaria nicht erreichbar ({endpoint}): {e}",
|
|
"TOLARIA_UNAVAILABLE")
|
|
|
|
# -- Read ---------------------------------------------------------------
|
|
|
|
def read(self, vault_path: str) -> Optional[str]:
|
|
"""Read-Back eines Vault-Objekts. Gibt Inhalt oder None (nicht vorhanden)."""
|
|
try:
|
|
resp = self._post(ENDPOINT_CONTENT, {"path": vault_path})
|
|
except TolariaWriteError as e:
|
|
# Enger Not-Found-Fall: HTTP 400 + eindeutige 'Invalid or missing
|
|
# path'-Semantik -> Objekt existiert nicht -> None (kein Fehler).
|
|
if e.http_code == 400 and TOLARIA_NOT_FOUND_MSG in (e.args[0] or ""):
|
|
return None
|
|
raise
|
|
if "error" in resp:
|
|
return None
|
|
return resp.get("content")
|
|
|
|
# -- Write (NUR SAVE) ---------------------------------------------------
|
|
|
|
def _require_token(self) -> str:
|
|
"""Fail-closed: fehlendes/leeres SAVE-Credential -> kein HTTP."""
|
|
token = self.save_token
|
|
if not token or not isinstance(token, str) or not token.strip():
|
|
raise TolariaWriteError(
|
|
"Tolaria SAVE-Credential fehlt oder ist leer "
|
|
"(fail-closed, kein Request gesendet)",
|
|
"CREDENTIAL_MISSING")
|
|
return token
|
|
|
|
def write(self, vault_path: str, content: str) -> Dict[str, Any]:
|
|
"""Schreibt ein Vault-Objekt (POST /save). SAVE-Scope.
|
|
|
|
Fail-closed: fehlendes/leeres SAVE-Credential -> lokaler Abbruch,
|
|
HTTP wird NICHT aufgerufen.
|
|
"""
|
|
token = self._require_token()
|
|
resp = self._post(ENDPOINT_SAVE, {"path": vault_path, "content": content},
|
|
auth_token=token)
|
|
if resp is None:
|
|
resp = {}
|
|
if "error" in resp:
|
|
raise TolariaWriteError(
|
|
f"Tolaria save fehlgeschlagen: {resp['error']}",
|
|
"INVALID_SCHEMA")
|
|
return resp
|