diff --git a/tolaria/c5-sync-service/delete_Dockerfile b/tolaria/c5-sync-service/delete_Dockerfile new file mode 100644 index 0000000..1603f0d --- /dev/null +++ b/tolaria/c5-sync-service/delete_Dockerfile @@ -0,0 +1,27 @@ +# AUTH.4D — c5-delete-executor (DELETE-only, fail-closed, verdrahtet) +FROM python:3.11-slim +# Kein echtes Credential im Image. Kein produktiver DELETE ohne injiziertes +# TOLARIA_DELETE_TOKEN (env_file, 0600). DELETE-only: kein SAVE-Code, kein +# forgejo_source_loader, kein git, keine write()-Capability. +# Kein Docker-Socket, kein SSH, keine Host-Root-Mounts. +RUN useradd --uid 10012 --create-home --shell /usr/sbin/nologin c5delete +WORKDIR /app +COPY --chown=10012:10012 \ + approval_payload.py \ + approval_signature.py \ + approval_state.py \ + approval_verifier.py \ + delete_executor_core.py \ + job_store.py \ + job_schema.py \ + job_state_machine.py \ + job_claim.py \ + delete_tolaria_client.py \ + delete_worker.py \ + delete_entrypoint.py \ + /app/ +USER 10012:10012 +ENV C5_DELETE_DB=/data/c5a_delete.db +# FIXED Tolaria Base URL (interne Docker-DNS-Adresse, NIE aus dem Job) +ENV C5_TOLARIA_BASE=http://tolaria:5173/api/vault +ENTRYPOINT ["python3", "/app/delete_entrypoint.py"] diff --git a/tolaria/c5-sync-service/delete_entrypoint.py b/tolaria/c5-sync-service/delete_entrypoint.py new file mode 100644 index 0000000..c734b0c --- /dev/null +++ b/tolaria/c5-sync-service/delete_entrypoint.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +""" +AUTH.4D — c5-delete-executor Runtime Entrypoint (DELETE-only, fail-closed). +Startet den verdrahteten DELETE-Worker-Loop. Health/Status-Modus. +""" +import json +import os +import sys + +from job_store import JobStore + +SCOPE = "DELETE" +DB_PATH = os.environ.get("C5_DELETE_DB", "/data/c5a_delete.db") +VERSION = "0.1.0-auth4d-wired" + + +def health() -> dict: + credential_present = bool(os.environ.get("TOLARIA_DELETE_TOKEN")) + public_key_present = bool(os.environ.get("C5_DELETE_PUBLIC_KEY_PEM")) + db_ready = False + try: + store = JobStore(DB_PATH, SCOPE) + store.close() + db_ready = True + except Exception: + db_ready = False + return { + "service": "c5-delete-executor", + "version": VERSION, + "mode": "fail_closed" if not credential_present else "credential_present", + "credential_present": credential_present, + "public_key_present": public_key_present, + "job_db_ready": db_ready, + "state": "idle" if not credential_present else "blocked", + "last_error": None, + } + + +def main() -> int: + if "--health" in sys.argv or "--status" in sys.argv: + print(json.dumps(health(), indent=2)) + return 0 + # Worker-Loop starten (verdrahtet, fail-closed) + from delete_worker import main as worker_main + return worker_main() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tolaria/c5-sync-service/delete_executor_core.py b/tolaria/c5-sync-service/delete_executor_core.py index 610b569..468cf28 100644 --- a/tolaria/c5-sync-service/delete_executor_core.py +++ b/tolaria/c5-sync-service/delete_executor_core.py @@ -168,6 +168,9 @@ class DeleteExecutorCore: return {"valid": False, "code": RC_APPROVAL_EXPIRED} if approval.get("consumed"): return {"valid": False, "code": RC_APPROVAL_CONSUMED} + # VALID_NONCE (Replay-Schutz): Nonce darf nicht bereits verwendet sein + if approval.get("nonce") in (approval.get("used_nonces") or set()): + return {"valid": False, "code": RC_APPROVAL_CONSUMED} # VALID_OBJECT if approval.get("object_id") != job["object_id"]: @@ -189,5 +192,9 @@ class DeleteExecutorCore: if approval.get("mission_id") != job["mission_id"]: return {"valid": False, "code": RC_MISSION_MISMATCH} + # VALID_DELETE_REQUEST (AUTH.3D: Approval bindet den DELETE-Request) + if approval.get("delete_request_id") != job["delete_request_id"]: + return {"valid": False, "code": RC_APPROVAL_MISMATCH} + # DELETE_CREDENTIAL (fail-closed — tolaria_delete prüft selbst) return {"valid": True} diff --git a/tolaria/c5-sync-service/delete_tolaria_client.py b/tolaria/c5-sync-service/delete_tolaria_client.py new file mode 100644 index 0000000..b70f7f4 --- /dev/null +++ b/tolaria/c5-sync-service/delete_tolaria_client.py @@ -0,0 +1,156 @@ +""" +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 diff --git a/tolaria/c5-sync-service/delete_worker.py b/tolaria/c5-sync-service/delete_worker.py new file mode 100644 index 0000000..a8b3646 --- /dev/null +++ b/tolaria/c5-sync-service/delete_worker.py @@ -0,0 +1,225 @@ +""" +AUTH.4D — worker.py (DELETE-scoped) +=================================== +DELETE-Worker-Loop für den c5-delete-executor. + +Ablauf (pro Job): + poll -> atomic claim -> validate -> load approval -> verify AUTH.3D + signature (Ed25519 PUBLIC KEY ONLY) -> check expiry/nonce/single-use -> + check object/path/commit/provenance/mission binding -> execute Tolaria + DELETE on FIXED endpoint -> verify/audit -> complete + +Eigenschaften: + * DELETE-only: claimt NUR C5_DELETE_OBJECT-Jobs. + * Der DELETE-Job selbst ist KEINE Approval. Job allein autorisiert NIE. + * Kein SAVE. Keine write()-Capability. Kein generic dispatcher. + * Kein shell/python/arbitrary URL/method/headers. + * POLL_INTERVAL, LEASE, MAX_ATTEMPTS, structured backoff, graceful SIGTERM. + * OUTCOME_UNKNOWN bei unklarem Delete-Ergebnis. KEIN blinder Retry. + * Fail-closed: ohne DELETE-Credential wird kein HTTP-DELETE ausgeführt. +""" + +from __future__ import annotations + +import logging +import os +import signal +import sys +import time +from typing import Any, Dict, Optional + +from approval_payload import parse_payload +from approval_signature import public_key_from_pem +from approval_state import ApprovalStateStore +from approval_verifier import ApprovalVerifier, ApprovalVerificationError +from delete_executor_core import DeleteExecutorCore +from job_store import JobStore +from delete_tolaria_client import TolariaClient + +SCOPE = "DELETE" +DB_PATH = os.environ.get("C5_DELETE_DB", "/data/c5a_delete.db") +VERSION = "0.1.0-auth4d-wired" + +# Konfiguration (ENV mit konservativen Defaults) +POLL_INTERVAL = float(os.environ.get("C5_POLL_INTERVAL", "5.0")) +LEASE_SECONDS = int(os.environ.get("C5_LEASE_SECONDS", "60")) +MAX_ATTEMPTS = int(os.environ.get("C5_MAX_ATTEMPTS", "3")) +BACKOFF_BASE = float(os.environ.get("C5_BACKOFF_BASE", "2.0")) +BACKOFF_MAX = float(os.environ.get("C5_BACKOFF_MAX", "60.0")) + +# Tolaria-Client (fixed base URL, DELETE-only) +TOLARIA_BASE = os.environ.get("C5_TOLARIA_BASE", "http://tolaria:5173/api/vault") +TOLARIA_DELETE_TOKEN = os.environ.get("TOLARIA_DELETE_TOKEN") + +# AUTH.3D Public Key (verify-only). NIE ein Private Key. +# Produktiv: PEM des Christian Public Key (Human-Gate-Phase). Fehlt -> fail-closed. +PUBLIC_VERIFY_KEY_PEM = os.environ.get("C5_DELETE_PUBLIC_KEY_PEM") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +log = logging.getLogger("c5-delete-worker") + +# Graceful Shutdown +_shutdown = False + + +def _handle_sigterm(signum, frame): + global _shutdown + log.info("SIGTERM empfangen — Worker fährt sauber herunter.") + _shutdown = True + + +def _handle_sigint(signum, frame): + global _shutdown + log.info("SIGINT empfangen — Worker fährt sauber herunter.") + _shutdown = True + + +signal.signal(signal.SIGTERM, _handle_sigterm) +signal.signal(signal.SIGINT, _handle_sigint) + + +def _backoff_delay(attempt: int) -> float: + """Structured exponential backoff (kein blinder Retry, nur transient).""" + delay = min(BACKOFF_MAX, BACKOFF_BASE * (2 ** (attempt - 1))) + return delay + + +def _build_verifier() -> Optional[ApprovalVerifier]: + """Baut den AUTH.3D-Verifier aus dem Public Key (verify-only). + + Fehlender/ungültiger Public Key -> None (fail-closed: keine Approval + kann verifiziert werden, kein DELETE wird ausgeführt). + """ + if not PUBLIC_VERIFY_KEY_PEM or not PUBLIC_VERIFY_KEY_PEM.strip(): + return None + try: + pub = public_key_from_pem(PUBLIC_VERIFY_KEY_PEM.encode("utf-8")) + except Exception as e: + log.error("Ungültiger Public Key PEM: %s", e) + return None + from approval_signature import key_id + return ApprovalVerifier(public_keys={key_id(pub): pub}) + + +def _build_worker() -> tuple[JobStore, DeleteExecutorCore]: + """Baut Store + Core mit injizierten Callables (Verdrahtung).""" + store = JobStore(DB_PATH, SCOPE) + client = TolariaClient(base_url=TOLARIA_BASE, delete_token=TOLARIA_DELETE_TOKEN) + verifier = _build_verifier() + # AUTH.3D Approval-State-Store (Single-Use / Nonce / Reservation). + # In Produktion: C5-DB (delete_approvals). Hier: In-Memory (isoliert). + approval_store = ApprovalStateStore() + + def approval_loader(approval_id: str) -> Optional[Dict[str, Any]]: + # Approval wird aus dem Approval-State-Store geladen (approval_id). + # Kein externer Approval-Service, keine Approval im Job-Payload. + return approval_store.get(approval_id) + + def approval_verify(approval: Dict[str, Any]) -> Dict[str, Any]: + # AUTH.3D-Verifikation (Ed25519 PUBLIC KEY ONLY). + # Fail-closed: kein Verifier / kein Public Key -> DELETE_DENIED. + if verifier is None: + return {"valid": False, "code": "DELETE_DENIED", "reason": "no public key"} + try: + result = verifier.verify_approval( + approval["raw_payload"], + approval["signature"], + expected_mission_id=approval["mission_id"], + expected_delete_request_id=approval["delete_request_id"], + expected_object_id=approval["object_id"], + expected_vault_path=approval["vault_path"], + expected_commit=approval["expected_commit"], + expected_provenance_hash=approval["expected_provenance_hash"], + expected_nonce=approval["nonce"], + consumed=approval.get("consumed", False), + state_allows_delete=approval.get("state_allows_delete", True), + used_nonces=approval.get("used_nonces", set()), + ) + return {"valid": True, "reason": "ok", "key_id": result.get("key_id")} + except ApprovalVerificationError as e: + return {"valid": False, "code": e.reason_code, "reason": e.message} + except Exception as e: + return {"valid": False, "code": "DELETE_DENIED", "reason": str(e)} + + def tolaria_delete(vault_path: str) -> Dict[str, Any]: + try: + client.delete(vault_path) + return {"status": "ok"} + except Exception as e: + # Unklar vs. bestätigt unterscheiden + code = getattr(e, "code", "TOLARIA_UNAVAILABLE") + if code == "CREDENTIAL_MISSING": + return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False} + if code in ("AUTH_FAILURE", "INVALID_SCHEMA"): + return {"status": "error", "code": code, "uncertain": False} + # Netzwerk/Timeout/5xx -> unklar (kein blinder Retry) + return {"status": "error", "code": "TOLARIA_UNAVAILABLE", "uncertain": True} + + core = DeleteExecutorCore(store, approval_loader, approval_verify, tolaria_delete) + return store, core + + +def _process_ready_job(store: JobStore, core: DeleteExecutorCore, job: Dict[str, Any]) -> None: + """Verarbeitet einen READY-Job (mit MAX_ATTEMPTS + backoff).""" + job_id = job["job_id"] + attempt = 0 + while attempt < MAX_ATTEMPTS: + attempt += 1 + log.info("Verarbeite Job %s (Versuch %d/%d)", job_id, attempt, MAX_ATTEMPTS) + try: + result = core.process_job(job_id, f"worker-{os.getpid()}") + state = result.get("state") + log.info("Job %s -> %s (result_code=%s)", + job_id, state, result.get("result_code")) + # Terminal-States: fertig. OUTCOME_UNKNOWN: kein blinder Retry. + if state in ("SUCCEEDED", "FAILED", "REJECTED", "OUTCOME_UNKNOWN"): + return + # READY/CLAIMED/EXECUTING (transient) -> backoff + erneut versuchen + if attempt < MAX_ATTEMPTS: + time.sleep(_backoff_delay(attempt)) + except Exception as e: + log.error("Job %s Fehler: %s", job_id, e) + if attempt < MAX_ATTEMPTS: + time.sleep(_backoff_delay(attempt)) + # MAX_ATTEMPTS erschöpft -> OUTCOME_UNKNOWN (kein blinder Retry) + try: + store.mark_outcome_unknown(job_id, f"worker-{os.getpid()}") + except Exception: + pass + + +def main() -> int: + log.info("c5-delete-worker v%s startet (scope=%s, poll=%.1fs, lease=%ds, max_attempts=%d)", + VERSION, SCOPE, POLL_INTERVAL, LEASE_SECONDS, MAX_ATTEMPTS) + if not TOLARIA_DELETE_TOKEN: + log.warning("TOLARIA_DELETE_TOKEN fehlt — fail-closed: kein HTTP-DELETE wird ausgeführt.") + if not PUBLIC_VERIFY_KEY_PEM: + log.warning("C5_DELETE_PUBLIC_KEY_PEM fehlt — fail-closed: keine Approval kann verifiziert werden.") + store, core = _build_worker() + try: + while not _shutdown: + try: + # READY-Jobs claimen und verarbeiten + ready_jobs = store.list_jobs(state="READY") + for job in ready_jobs: + if _shutdown: + break + _process_ready_job(store, core, job) + except Exception as e: + log.error("Worker-Loop-Fehler: %s", e) + # Poll-Intervall (graceful: bei SIGTERM sofort raus) + for _ in range(int(POLL_INTERVAL * 10)): + if _shutdown: + break + time.sleep(0.1) + finally: + store.close() + log.info("Worker beendet.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())