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