""" AUTH.4D — tolaria_client.py (DELETE-scoped) =========================================== Isolierter Tolaria-Client für den DELETE-Executor (DELETE-only). Eigenschaften: * FIXED Base URL (aus ENV C5_TOLARIA_BASE oder Default http://tolaria:5173/api/vault) — NIE aus dem Job. Keine arbitrary URL. * DELETE Endpoint fest: /delete. Read-Back fest: /content. * Keine arbitrary headers/method. * Fail-closed: kein HTTP ohne DELETE-Credential (TOLARIA_DELETE_TOKEN). * OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry). * Token-Wert wird NIE geloggt. Nur DELETE-Scope. Kein SAVE. Kein Master-Token. Keine write()-Capability. """ 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_DELETE_TOKEN = "TOLARIA_DELETE_TOKEN" # Feste Endpoints (keine arbitrary URL/method) ENDPOINT_DELETE = "delete" 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 TolariaDeleteError(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 (DELETE-only). read() — POST /content (Read-Back / Verifikation) delete() — POST /delete (NUR DELETE; fail-closed ohne Credential) """ def __init__(self, base_url: Optional[str] = None, timeout: float = 15.0, delete_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 # DELETE-Credential explizit injiziert (kein verstecktes globales). # Fehlend/leer -> fail-closed beim mutierenden Aufruf. self.delete_token = delete_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 DELETE). 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 TolariaDeleteError( 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). vault_path ist der KANONISCHE RELATIVE Pfad (AUTH.4C2 PATH CONTRACT REPAIR, OPTION A) — wird unverändert an Tolaria gesendet. Kein /app/vault/-Prefix-Stripping, keine versteckte Rewrite-Logik. """ try: resp = self._post(ENDPOINT_CONTENT, {"path": vault_path}) except TolariaDeleteError 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") # -- Delete (NUR DELETE) ------------------------------------------------ def _require_token(self) -> str: """Fail-closed: fehlendes/leeres DELETE-Credential -> kein HTTP.""" token = self.delete_token if not token or not isinstance(token, str) or not token.strip(): raise TolariaDeleteError( "Tolaria DELETE-Credential fehlt oder ist leer " "(fail-closed, kein Request gesendet)", "CREDENTIAL_MISSING") return token def delete(self, vault_path: str) -> Dict[str, Any]: """Löscht ein Vault-Objekt (POST /delete). DELETE-Scope. vault_path ist der KANONISCHE RELATIVE Pfad (AUTH.4C2 PATH CONTRACT REPAIR, OPTION A) — wird unverändert an Tolaria gesendet. Kein /app/vault/-Prefix-Stripping, keine versteckte Rewrite-Logik. Fail-closed: fehlendes/leeres DELETE-Credential -> lokaler Abbruch, HTTP wird NICHT aufgerufen. """ token = self._require_token() resp = self._post(ENDPOINT_DELETE, {"path": vault_path}, auth_token=token) if resp is None: resp = {} if "error" in resp: raise TolariaDeleteError( f"Tolaria delete fehlgeschlagen: {resp['error']}", "INVALID_SCHEMA") return resp