AUTH.4D: DELETE-scoped executor + delete_request_id binding in SoT core
- delete_worker.py: DELETE-scoped worker loop (ApprovalStateStore, fail-closed) - delete_tolaria_client.py: DELETE-only Tolaria client (fixed /delete endpoint) - delete_entrypoint.py: DELETE runtime entrypoint (health/status + worker) - delete_Dockerfile: DELETE executor image (no SAVE code, no credentials) - delete_executor_core.py: minimal SoT patch — VALID_NONCE replay guard + VALID_DELETE_REQUEST binding (approval binds delete_request_id, fail-closed APPROVAL_MISMATCH). Closes P10/T29 security gap in productive SoT. AUTH.4D P14. No deployment, no token injection, no key provisioning.
This commit is contained in:
parent
5f3adda73f
commit
9c2d6e365a
5 changed files with 464 additions and 0 deletions
27
tolaria/c5-sync-service/delete_Dockerfile
Normal file
27
tolaria/c5-sync-service/delete_Dockerfile
Normal file
|
|
@ -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"]
|
||||||
49
tolaria/c5-sync-service/delete_entrypoint.py
Normal file
49
tolaria/c5-sync-service/delete_entrypoint.py
Normal file
|
|
@ -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())
|
||||||
|
|
@ -168,6 +168,9 @@ class DeleteExecutorCore:
|
||||||
return {"valid": False, "code": RC_APPROVAL_EXPIRED}
|
return {"valid": False, "code": RC_APPROVAL_EXPIRED}
|
||||||
if approval.get("consumed"):
|
if approval.get("consumed"):
|
||||||
return {"valid": False, "code": RC_APPROVAL_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
|
# VALID_OBJECT
|
||||||
if approval.get("object_id") != job["object_id"]:
|
if approval.get("object_id") != job["object_id"]:
|
||||||
|
|
@ -189,5 +192,9 @@ class DeleteExecutorCore:
|
||||||
if approval.get("mission_id") != job["mission_id"]:
|
if approval.get("mission_id") != job["mission_id"]:
|
||||||
return {"valid": False, "code": RC_MISSION_MISMATCH}
|
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)
|
# DELETE_CREDENTIAL (fail-closed — tolaria_delete prüft selbst)
|
||||||
return {"valid": True}
|
return {"valid": True}
|
||||||
|
|
|
||||||
156
tolaria/c5-sync-service/delete_tolaria_client.py
Normal file
156
tolaria/c5-sync-service/delete_tolaria_client.py
Normal file
|
|
@ -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
|
||||||
225
tolaria/c5-sync-service/delete_worker.py
Normal file
225
tolaria/c5-sync-service/delete_worker.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Reference in a new issue