""" 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