AUTH.3E: Executor Command Channel + Runtime Boundary Contract
- job_schema: geschlossene Job-Type-Allowlist (C5_SAVE_OBJECT/C5_DELETE_OBJECT), Pfad-/Längen-Validierung - job_state_machine: deterministische States (CREATED/READY/CLAIMED/EXECUTING/SUCCEEDED/FAILED) - job_claim: atomare Claim-/Lease-/Recovery-Logik (kein TOCTOU) - job_store: getrennte SQLite-Inbox-DBs (c5a_save.db/c5a_delete.db), delegiert an job_claim - save_executor_core: SAVE-only, Content-Rekonstruktion, Provenance-Validierung - delete_executor_core: DELETE-only, AUTH.3D-Composition, TOCTOU-Defense (Re-Read nach Claim) - test_job_channel: T1-T40 + adversarial (72 Tests) - test_job_channel_adversarial: adversarial + Substitution + DB-Manipulation - sensitivity_proof_auth3e: Mutationen A-L (12/12 Invarianten PRESENT) - AUTH3B/3C/3D/3E_DESIGN: autoritative Security-Dokumentation (e25 Reconciliation) COMMAND != AUTHORIZATION. Kein generischer Dispatcher. RQ credential-free. Keine produktive Mutation. Keine echten Credentials.
This commit is contained in:
parent
9c8d239ae5
commit
10761f52b2
13 changed files with 4990 additions and 0 deletions
186
tolaria/c5-sync-service/delete_executor_core.py
Normal file
186
tolaria/c5-sync-service/delete_executor_core.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — delete_executor_core.py
|
||||||
|
==================================
|
||||||
|
DELETE-Executor-Logik (DELETE-only, AUTH.3D-Validierung).
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
|
* DELETE-only: claimt NUR C5_DELETE_OBJECT-Jobs
|
||||||
|
* Der DELETE-Job selbst ist KEINE Approval
|
||||||
|
* AUTH.3D-Composition: VALID_JOB AND VALID_STATE AND VALID_AUTH3D_SIGNATURE
|
||||||
|
AND VALID_NONCE AND VALID_EXPIRY AND NOT_CONSUMED AND VALID_OBJECT AND
|
||||||
|
VALID_PATH AND VALID_COMMIT AND VALID_PROVENANCE AND DELETE_CREDENTIAL
|
||||||
|
* Kein Faktor ersetzt einen anderen
|
||||||
|
* Fail-closed: kein HTTP ohne DELETE-Credential
|
||||||
|
* OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry)
|
||||||
|
* Ein gestarteter DELETE wird bei Lease-Expiry NICHT blind wiederholt
|
||||||
|
|
||||||
|
Isoliert implementiert (KEIN produktiver Container). Nutzt job_store + job_schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from job_schema import JOB_TYPE_DELETE
|
||||||
|
from job_store import JobStore
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result-Codes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RC_OK = "OK"
|
||||||
|
RC_CREDENTIAL_MISSING = "CREDENTIAL_MISSING"
|
||||||
|
RC_APPROVAL_MISSING = "APPROVAL_MISSING"
|
||||||
|
RC_APPROVAL_INVALID = "APPROVAL_INVALID"
|
||||||
|
RC_APPROVAL_EXPIRED = "APPROVAL_EXPIRED"
|
||||||
|
RC_APPROVAL_CONSUMED = "APPROVAL_CONSUMED"
|
||||||
|
RC_APPROVAL_MISMATCH = "APPROVAL_MISMATCH"
|
||||||
|
RC_OBJECT_MISMATCH = "OBJECT_MISMATCH"
|
||||||
|
RC_PATH_MISMATCH = "PATH_MISMATCH"
|
||||||
|
RC_COMMIT_MISMATCH = "COMMIT_MISMATCH"
|
||||||
|
RC_PROVENANCE_MISMATCH = "PROVENANCE_MISMATCH"
|
||||||
|
RC_MISSION_MISMATCH = "MISSION_MISMATCH"
|
||||||
|
RC_DELETE_DENIED = "DELETE_DENIED"
|
||||||
|
RC_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN"
|
||||||
|
RC_REJECTED = "REJECTED"
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteExecutorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteExecutorCore:
|
||||||
|
"""
|
||||||
|
DELETE-Executor. worker_scope="DELETE" (nur DELETE-Jobs claimbar).
|
||||||
|
|
||||||
|
approval_loader: Callable[[str], Optional[Dict[str, Any]]] — lädt die
|
||||||
|
AUTH.3D-Approval (approval_id) -> dict oder None.
|
||||||
|
approval_verify: Callable[[Dict[str, Any]], Dict[str, Any]] — verifiziert die
|
||||||
|
AUTH.3D-Signatur (Ed25519 PUBLIC KEY ONLY) -> {"valid": bool, "reason": str}.
|
||||||
|
tolaria_delete: Callable[[str], Dict[str, Any]] — führt den Tolaria-DELETE aus
|
||||||
|
(vault_path) -> {"status": "ok"|"error", "uncertain": bool}.
|
||||||
|
Muss fail-closed sein (kein HTTP ohne Credential).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: JobStore,
|
||||||
|
approval_loader: Callable[[str], Optional[Dict[str, Any]]],
|
||||||
|
approval_verify: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||||
|
tolaria_delete: Callable[[str], Dict[str, Any]],
|
||||||
|
):
|
||||||
|
if store.worker_scope != "DELETE":
|
||||||
|
raise DeleteExecutorError("DeleteExecutorCore requires worker_scope='DELETE'")
|
||||||
|
self.store = store
|
||||||
|
self.approval_loader = approval_loader
|
||||||
|
self.approval_verify = approval_verify
|
||||||
|
self.tolaria_delete = tolaria_delete
|
||||||
|
|
||||||
|
# -- Hauptverarbeitung --------------------------------------------------
|
||||||
|
|
||||||
|
def process_job(self, job_id: str, worker_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verarbeitet einen DELETE-Job durch die State Machine.
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. Job laden (muss existieren)
|
||||||
|
2. Job-Type-Scope prüfen (nur DELETE)
|
||||||
|
3. READY -> CLAIMED (atomarer Claim)
|
||||||
|
4. CLAIMED -> EXECUTING
|
||||||
|
5. AUTH.3D-Composition validieren (Job + Approval exakt zusammenpassen)
|
||||||
|
6. Tolaria-DELETE ausführen
|
||||||
|
7. Ergebnis: SUCCEEDED / FAILED / OUTCOME_UNKNOWN / REJECTED
|
||||||
|
"""
|
||||||
|
job = self.store.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise DeleteExecutorError(f"job not found: {job_id}")
|
||||||
|
|
||||||
|
# Scope: nur DELETE-Jobs
|
||||||
|
if job["job_type"] != JOB_TYPE_DELETE:
|
||||||
|
self.store.mark_rejected(job_id, RC_REJECTED)
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
# Atomarer Claim
|
||||||
|
try:
|
||||||
|
self.store.claim_job(job_id, worker_id)
|
||||||
|
except Exception:
|
||||||
|
# Nicht claimbar (bereits geclaimt) -> kein Doppel-Delete
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
self.store.begin_execution(job_id, worker_id)
|
||||||
|
|
||||||
|
# AUTH.3D-Composition (alle Faktoren, keiner ersetzt einen anderen).
|
||||||
|
# Job NACH dem Claim neu lesen, damit eine Payload-Manipulation nach
|
||||||
|
# dem Claim (TOCTOU) erkannt wird — Defense in depth.
|
||||||
|
job = self.store.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
self.store.mark_failed(job_id, RC_DELETE_DENIED, worker_id)
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
gate = self._validate_auth3d_composition(job)
|
||||||
|
if gate["valid"] is not True:
|
||||||
|
self.store.mark_failed(job_id, gate["code"], worker_id)
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
# Tolaria-DELETE ausführen (fail-closed)
|
||||||
|
result = self.tolaria_delete(job["vault_path"])
|
||||||
|
status = result.get("status")
|
||||||
|
if status == "ok":
|
||||||
|
self.store.mark_succeeded(job_id, worker_id)
|
||||||
|
elif status == "error" and result.get("uncertain"):
|
||||||
|
self.store.mark_outcome_unknown(job_id, worker_id)
|
||||||
|
else:
|
||||||
|
self.store.mark_failed(job_id, result.get("code", RC_DELETE_DENIED), worker_id)
|
||||||
|
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
# -- AUTH.3D-Composition -------------------------------------------------
|
||||||
|
|
||||||
|
def _validate_auth3d_composition(self, job: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validiert die AUTH.3D-Composition. Alle Faktoren müssen gelten:
|
||||||
|
VALID_JOB AND VALID_STATE AND VALID_AUTH3D_SIGNATURE AND VALID_NONCE
|
||||||
|
AND VALID_EXPIRY AND NOT_CONSUMED AND VALID_OBJECT AND VALID_PATH
|
||||||
|
AND VALID_COMMIT AND VALID_PROVENANCE AND DELETE_CREDENTIAL
|
||||||
|
|
||||||
|
Rückgabe: {"valid": True} oder {"valid": False, "code": RC_...}
|
||||||
|
"""
|
||||||
|
# VALID_JOB (Schema bereits beim Erzeugen validiert)
|
||||||
|
# VALID_STATE (EXECUTING — bereits durch begin_execution)
|
||||||
|
|
||||||
|
# Approval laden
|
||||||
|
approval = self.approval_loader(job["approval_id"])
|
||||||
|
if approval is None:
|
||||||
|
return {"valid": False, "code": RC_APPROVAL_MISSING}
|
||||||
|
|
||||||
|
# VALID_AUTH3D_SIGNATURE (Ed25519 PUBLIC KEY ONLY)
|
||||||
|
sig = self.approval_verify(approval)
|
||||||
|
if sig.get("valid") is not True:
|
||||||
|
return {"valid": False, "code": RC_APPROVAL_INVALID}
|
||||||
|
|
||||||
|
# VALID_NONCE / VALID_EXPIRY / NOT_CONSUMED
|
||||||
|
if approval.get("expired"):
|
||||||
|
return {"valid": False, "code": RC_APPROVAL_EXPIRED}
|
||||||
|
if approval.get("consumed"):
|
||||||
|
return {"valid": False, "code": RC_APPROVAL_CONSUMED}
|
||||||
|
|
||||||
|
# VALID_OBJECT
|
||||||
|
if approval.get("object_id") != job["object_id"]:
|
||||||
|
return {"valid": False, "code": RC_OBJECT_MISMATCH}
|
||||||
|
|
||||||
|
# VALID_PATH
|
||||||
|
if approval.get("vault_path") != job["vault_path"]:
|
||||||
|
return {"valid": False, "code": RC_PATH_MISMATCH}
|
||||||
|
|
||||||
|
# VALID_COMMIT
|
||||||
|
if approval.get("expected_commit") != job["expected_commit"]:
|
||||||
|
return {"valid": False, "code": RC_COMMIT_MISMATCH}
|
||||||
|
|
||||||
|
# VALID_PROVENANCE
|
||||||
|
if approval.get("expected_provenance_hash") != job["expected_provenance_hash"]:
|
||||||
|
return {"valid": False, "code": RC_PROVENANCE_MISMATCH}
|
||||||
|
|
||||||
|
# VALID_MISSION
|
||||||
|
if approval.get("mission_id") != job["mission_id"]:
|
||||||
|
return {"valid": False, "code": RC_MISSION_MISMATCH}
|
||||||
|
|
||||||
|
# DELETE_CREDENTIAL (fail-closed — tolaria_delete prüft selbst)
|
||||||
|
return {"valid": True}
|
||||||
149
tolaria/c5-sync-service/job_claim.py
Normal file
149
tolaria/c5-sync-service/job_claim.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — job_claim.py
|
||||||
|
======================
|
||||||
|
Atomarer Claim / Lease / Concurrency für den Executor Command Channel.
|
||||||
|
|
||||||
|
Garantien:
|
||||||
|
* Claim ist ATOMAR (SQLite-Transaktion mit Status-Bedingung) — kein TOCTOU.
|
||||||
|
* Zwei SAVE-Worker: nur einer kann denselben Job claimen.
|
||||||
|
* Zwei DELETE-Worker: nur einer kann denselben DELETE-Job claimen.
|
||||||
|
* Ein bereits gestarteter DELETE wird bei Lease-Expiry NICHT blind wiederholt
|
||||||
|
(-> OUTCOME_UNKNOWN, nicht zurück zu READY).
|
||||||
|
* Stale Claims werden via Lease-Expiry erkannt.
|
||||||
|
|
||||||
|
Isoliert implementiert (KEIN produktiver Container).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from job_state_machine import ST_CLAIMED, ST_READY
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fehler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class ClaimError(Exception):
|
||||||
|
"""Basis-Fehler für Claim/Lease."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobAlreadyClaimedError(ClaimError):
|
||||||
|
"""Job ist nicht claimbar (bereits geclaimt oder Lease aktiv)."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobNotFoundError(ClaimError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Atomarer Claim
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def atomic_claim(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
job_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int = 60,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Führt einen atomaren Claim aus: READY -> CLAIMED, nur wenn Lease abgelaufen
|
||||||
|
oder nie gesetzt. Kein TOCTOU (Status-Bedingung in der UPDATE-WHERE-Klausel).
|
||||||
|
|
||||||
|
Rückgabe: der geclaimte Job (als dict).
|
||||||
|
Wirft JobAlreadyClaimedError, wenn der Job nicht claimbar ist.
|
||||||
|
"""
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
lease_until = now + lease_seconds * 1000
|
||||||
|
claim_id = str(uuid.uuid4())
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = ?, worker_id = ?, claim_id = ?, claimed_at = ?,
|
||||||
|
lease_until = ?, attempt_count = attempt_count + 1, updated_at = ?
|
||||||
|
WHERE job_id = ?
|
||||||
|
AND state = ?
|
||||||
|
AND (lease_until IS NULL OR lease_until < ?)
|
||||||
|
""",
|
||||||
|
(ST_CLAIMED, worker_id, claim_id, now, lease_until, now,
|
||||||
|
job_id, ST_READY, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
raise ClaimError(f"atomic_claim failed: {e}") from e
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise JobAlreadyClaimedError(f"job not claimable: {job_id}")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM jobs WHERE job_id = ?", (job_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise JobNotFoundError(f"job not found after claim: {job_id}")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lease
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def renew_lease(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
job_id: str,
|
||||||
|
lease_seconds: int = 60,
|
||||||
|
) -> None:
|
||||||
|
"""Verlängert die Lease eines CLAIMED/EXECUTING-Jobs."""
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
lease_until = now + lease_seconds * 1000
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE jobs SET lease_until = ?, updated_at = ? WHERE job_id = ?",
|
||||||
|
(lease_until, now, job_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def is_lease_expired(lease_until: Optional[int], now: Optional[int] = None) -> bool:
|
||||||
|
"""True, wenn die Lease abgelaufen ist (oder nie gesetzt)."""
|
||||||
|
if lease_until is None:
|
||||||
|
return True
|
||||||
|
now = now if now is not None else int(time.time() * 1000)
|
||||||
|
return lease_until < now
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Crash-Recovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def recover_stale_claims(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
job_type: str,
|
||||||
|
now: Optional[int] = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Findet stale CLAIMED-Jobs (Lease abgelaufen) und überführt sie:
|
||||||
|
* SAVE: zurück zu READY (wieder claimbar, RETRYABLE)
|
||||||
|
* DELETE: zu OUTCOME_UNKNOWN (NICHT blind wiederholen)
|
||||||
|
|
||||||
|
Rückgabe: Anzahl der recovered Jobs.
|
||||||
|
"""
|
||||||
|
from job_state_machine import ST_OUTCOME_UNKNOWN
|
||||||
|
|
||||||
|
now = now if now is not None else int(time.time() * 1000)
|
||||||
|
stale = conn.execute(
|
||||||
|
"SELECT job_id FROM jobs WHERE state = ? AND lease_until IS NOT NULL AND lease_until < ?",
|
||||||
|
(ST_CLAIMED, now),
|
||||||
|
).fetchall()
|
||||||
|
recovered = 0
|
||||||
|
for row in stale:
|
||||||
|
job_id = row["job_id"]
|
||||||
|
if job_type == "C5_SAVE_OBJECT":
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?",
|
||||||
|
(ST_READY, now, job_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?",
|
||||||
|
(ST_OUTCOME_UNKNOWN, now, job_id),
|
||||||
|
)
|
||||||
|
recovered += 1
|
||||||
|
conn.commit()
|
||||||
|
return recovered
|
||||||
290
tolaria/c5-sync-service/job_schema.py
Normal file
290
tolaria/c5-sync-service/job_schema.py
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — job_schema.py
|
||||||
|
=======================
|
||||||
|
Geschlossene Job-Schema-Validierung für den Executor Command Channel.
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
|
* job_type als geschlossene Allowlist (Enum): C5_SAVE_OBJECT | C5_DELETE_OBJECT
|
||||||
|
* Typisierte Felder (uuid, sha256-hex, ISO8601-UTC, enum)
|
||||||
|
* KEIN generischer Dispatcher: keine command=/endpoint=/url=/method=/shell=/
|
||||||
|
python=/handler=-Felder erlaubt
|
||||||
|
* Unknown job_type -> REJECTED
|
||||||
|
* Extra privileged fields -> REJECTED
|
||||||
|
* Malformed schema -> REJECTED
|
||||||
|
* Command-Injection-Defense: Jobs dürfen niemals shell/executable/python/url/
|
||||||
|
headers/credentials/sql/docker/ssh enthalten
|
||||||
|
|
||||||
|
Isoliert implementiert (KEIN produktiver Container). Wiederverwendet die
|
||||||
|
AUTH.3D-Payload-Normalisierung (approval_payload._normalize_path) für Pfade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Geschlossene Job-Type-Allowlist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
JOB_TYPE_SAVE = "C5_SAVE_OBJECT"
|
||||||
|
JOB_TYPE_DELETE = "C5_DELETE_OBJECT"
|
||||||
|
JOB_TYPES = frozenset({JOB_TYPE_SAVE, JOB_TYPE_DELETE})
|
||||||
|
|
||||||
|
JOB_VERSION = 1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verbotene privilegierte Felder (Command-Injection-Defense)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Diese Felder dürfen in KEINEM Job vorkommen. Ihr Vorhandensein -> REJECTED.
|
||||||
|
FORBIDDEN_FIELDS = frozenset({
|
||||||
|
"command", "endpoint", "url", "method", "shell", "python", "handler",
|
||||||
|
"exec", "executable", "script", "headers", "authorization", "credential",
|
||||||
|
"token", "password", "secret", "sql", "query", "docker", "ssh",
|
||||||
|
"base_url", "http_method", "auth_header",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Erlaubte Felder je Job-Type (geschlossene Schemata)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BASE_FIELDS = frozenset({
|
||||||
|
"job_version", "job_id", "mission_id", "job_type", "object_id",
|
||||||
|
"vault_path", "created_at", "idempotency_key",
|
||||||
|
})
|
||||||
|
|
||||||
|
SAVE_FIELDS = BASE_FIELDS | frozenset({
|
||||||
|
"source_commit", "provenance_hash", "expected_state",
|
||||||
|
})
|
||||||
|
|
||||||
|
DELETE_FIELDS = BASE_FIELDS | frozenset({
|
||||||
|
"delete_request_id", "expected_commit", "expected_provenance_hash",
|
||||||
|
"approval_id",
|
||||||
|
})
|
||||||
|
|
||||||
|
ALLOWED_FIELDS = {
|
||||||
|
JOB_TYPE_SAVE: SAVE_FIELDS,
|
||||||
|
JOB_TYPE_DELETE: DELETE_FIELDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validierungs-Helfer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||||
|
_ISO8601_UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_uuid(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(_UUID_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sha256(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(_SHA256_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_iso8601_utc(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(_ISO8601_UTC_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nonempty_str(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and len(value) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(path: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalisiert einen Vault-Pfad (keine //, ., .., trailing slash).
|
||||||
|
Wiederverwendet die AUTH.3D-Pfad-Normalisierung.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from approval_payload import _normalize_path as _ap_normalize
|
||||||
|
return _ap_normalize(path)
|
||||||
|
except Exception:
|
||||||
|
# Fallback (isoliert): eigene Normalisierung
|
||||||
|
parts = [p for p in path.split("/") if p not in ("", ".")]
|
||||||
|
if ".." in parts:
|
||||||
|
raise ValueError("path traversal")
|
||||||
|
return "/" + "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_path(path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Pfad-Sicherheitsprüfung (Executor-seitig, Defense in depth).
|
||||||
|
Erlaubt nur Pfade unterhalb des Vault-Roots, keine Traversal,
|
||||||
|
keine absoluten Host-Pfade, keine Unicode-Ambiguität, keine
|
||||||
|
URL-Encodierung, keine Sonderzeichen.
|
||||||
|
"""
|
||||||
|
if not isinstance(path, str) or not path:
|
||||||
|
return "path must be a non-empty string"
|
||||||
|
if len(path) > 1024:
|
||||||
|
return "path too long"
|
||||||
|
if "\x00" in path:
|
||||||
|
return "path contains null byte"
|
||||||
|
# Kein absoluter Host-Pfad (nur /app/vault/... erlaubt)
|
||||||
|
if not path.startswith("/app/vault/"):
|
||||||
|
return "path must be under /app/vault/"
|
||||||
|
# Zeichensatz-Whitelist: nur sichere Pfadzeichen.
|
||||||
|
# Schließt URL-Encodierung (%), Unicode-Homoglyphen, Leerzeichen,
|
||||||
|
# Steuerzeichen und Sonderzeichen aus.
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9/._-]+", path):
|
||||||
|
return "path contains disallowed characters"
|
||||||
|
# Traversal / Normalisierung
|
||||||
|
try:
|
||||||
|
norm = _normalize_path(path)
|
||||||
|
except ValueError:
|
||||||
|
return "path traversal detected"
|
||||||
|
if norm != path:
|
||||||
|
return "path not normalized"
|
||||||
|
# Unicode-Ambiguität: keine Homoglyphen-/Normalisierungs-Angriffe
|
||||||
|
import unicodedata
|
||||||
|
if unicodedata.normalize("NFC", path) != path:
|
||||||
|
return "path not NFC-normalized"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Job-Schema-Validierung
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class JobSchemaError(ValueError):
|
||||||
|
"""Basis-Fehler für Schema-Validierung."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobRejectedError(JobSchemaError):
|
||||||
|
"""Job wurde REJECTED (Schema/Allowlist/Injection-Fehler)."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validiert einen Job gegen das geschlossene Schema.
|
||||||
|
|
||||||
|
Rückgabe: normalisierter Job (bei Erfolg).
|
||||||
|
Wirft JobRejectedError bei:
|
||||||
|
* unknown job_type
|
||||||
|
* malformed schema
|
||||||
|
* extra privileged fields
|
||||||
|
* Command-Injection-Felder
|
||||||
|
* Pfad-Verletzungen
|
||||||
|
"""
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
raise JobRejectedError("job must be a dict")
|
||||||
|
|
||||||
|
# 1. job_type muss in der geschlossenen Allowlist sein
|
||||||
|
job_type = job.get("job_type")
|
||||||
|
if not isinstance(job_type, str) or job_type not in JOB_TYPES:
|
||||||
|
raise JobRejectedError(f"unknown job_type: {job_type!r}")
|
||||||
|
|
||||||
|
# 2. job_version muss stimmen
|
||||||
|
if job.get("job_version") != JOB_VERSION:
|
||||||
|
raise JobRejectedError(f"unknown job_version: {job.get('job_version')!r}")
|
||||||
|
|
||||||
|
# 3. Keine privilegierten/verbotenen Felder
|
||||||
|
extra = set(job.keys()) - ALLOWED_FIELDS[job_type]
|
||||||
|
if extra:
|
||||||
|
raise JobRejectedError(f"extra/unknown fields: {sorted(extra)}")
|
||||||
|
forbidden = set(job.keys()) & FORBIDDEN_FIELDS
|
||||||
|
if forbidden:
|
||||||
|
raise JobRejectedError(f"forbidden privileged fields: {sorted(forbidden)}")
|
||||||
|
|
||||||
|
# 4. Pflichtfelder vorhanden
|
||||||
|
required = ALLOWED_FIELDS[job_type]
|
||||||
|
missing = required - set(job.keys())
|
||||||
|
if missing:
|
||||||
|
raise JobRejectedError(f"missing required fields: {sorted(missing)}")
|
||||||
|
|
||||||
|
# 5. Typ-Validierung
|
||||||
|
if not _is_uuid(job.get("job_id")):
|
||||||
|
raise JobRejectedError("job_id must be a UUID")
|
||||||
|
if not _is_uuid(job.get("mission_id")):
|
||||||
|
raise JobRejectedError("mission_id must be a UUID")
|
||||||
|
if not _is_uuid(job.get("idempotency_key")):
|
||||||
|
raise JobRejectedError("idempotency_key must be a UUID")
|
||||||
|
if not _is_nonempty_str(job.get("object_id")):
|
||||||
|
raise JobRejectedError("object_id must be a non-empty string")
|
||||||
|
if len(job.get("object_id", "")) > 256:
|
||||||
|
raise JobRejectedError("object_id too long")
|
||||||
|
if not _is_iso8601_utc(job.get("created_at")):
|
||||||
|
raise JobRejectedError("created_at must be ISO8601 UTC (YYYY-MM-DDTHH:MM:SSZ)")
|
||||||
|
|
||||||
|
# 6. Pfad-Sicherheit
|
||||||
|
path_err = _validate_path(job.get("vault_path"))
|
||||||
|
if path_err:
|
||||||
|
raise JobRejectedError(f"vault_path invalid: {path_err}")
|
||||||
|
|
||||||
|
# 7. Job-Type-spezifische Felder
|
||||||
|
if job_type == JOB_TYPE_SAVE:
|
||||||
|
if not _is_sha256(job.get("source_commit")):
|
||||||
|
raise JobRejectedError("source_commit must be a sha256 hex")
|
||||||
|
if not _is_sha256(job.get("provenance_hash")):
|
||||||
|
raise JobRejectedError("provenance_hash must be a sha256 hex")
|
||||||
|
if not _is_nonempty_str(job.get("expected_state")):
|
||||||
|
raise JobRejectedError("expected_state must be a non-empty string")
|
||||||
|
elif job_type == JOB_TYPE_DELETE:
|
||||||
|
if not _is_uuid(job.get("delete_request_id")):
|
||||||
|
raise JobRejectedError("delete_request_id must be a UUID")
|
||||||
|
if not _is_sha256(job.get("expected_commit")):
|
||||||
|
raise JobRejectedError("expected_commit must be a sha256 hex")
|
||||||
|
if not _is_sha256(job.get("expected_provenance_hash")):
|
||||||
|
raise JobRejectedError("expected_provenance_hash must be a sha256 hex")
|
||||||
|
if not _is_uuid(job.get("approval_id")):
|
||||||
|
raise JobRejectedError("approval_id must be a UUID")
|
||||||
|
|
||||||
|
# 8. Keine Duplicate-Keys (JSON-Duplikat-Angriff)
|
||||||
|
# (wird durch parse_payload in approval_payload abgedeckt; hier defensiv)
|
||||||
|
return dict(job)
|
||||||
|
|
||||||
|
|
||||||
|
def make_save_job(
|
||||||
|
job_id: str,
|
||||||
|
mission_id: str,
|
||||||
|
object_id: str,
|
||||||
|
vault_path: str,
|
||||||
|
source_commit: str,
|
||||||
|
provenance_hash: str,
|
||||||
|
expected_state: str,
|
||||||
|
created_at: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Erzeugt einen kanonischen SAVE-Job (validiert)."""
|
||||||
|
job = {
|
||||||
|
"job_version": JOB_VERSION,
|
||||||
|
"job_id": job_id,
|
||||||
|
"mission_id": mission_id,
|
||||||
|
"job_type": JOB_TYPE_SAVE,
|
||||||
|
"object_id": object_id,
|
||||||
|
"vault_path": vault_path,
|
||||||
|
"source_commit": source_commit,
|
||||||
|
"provenance_hash": provenance_hash,
|
||||||
|
"expected_state": expected_state,
|
||||||
|
"created_at": created_at,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
}
|
||||||
|
return validate_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
def make_delete_job(
|
||||||
|
job_id: str,
|
||||||
|
mission_id: str,
|
||||||
|
delete_request_id: str,
|
||||||
|
object_id: str,
|
||||||
|
vault_path: str,
|
||||||
|
expected_commit: str,
|
||||||
|
expected_provenance_hash: str,
|
||||||
|
approval_id: str,
|
||||||
|
created_at: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Erzeugt einen kanonischen DELETE-Job (validiert)."""
|
||||||
|
job = {
|
||||||
|
"job_version": JOB_VERSION,
|
||||||
|
"job_id": job_id,
|
||||||
|
"mission_id": mission_id,
|
||||||
|
"delete_request_id": delete_request_id,
|
||||||
|
"job_type": JOB_TYPE_DELETE,
|
||||||
|
"object_id": object_id,
|
||||||
|
"vault_path": vault_path,
|
||||||
|
"expected_commit": expected_commit,
|
||||||
|
"expected_provenance_hash": expected_provenance_hash,
|
||||||
|
"approval_id": approval_id,
|
||||||
|
"created_at": created_at,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
}
|
||||||
|
return validate_job(job)
|
||||||
103
tolaria/c5-sync-service/job_state_machine.py
Normal file
103
tolaria/c5-sync-service/job_state_machine.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — job_state_machine.py
|
||||||
|
===============================
|
||||||
|
Deterministische Job-State-Machine für den Executor Command Channel.
|
||||||
|
|
||||||
|
States:
|
||||||
|
CREATED, READY, CLAIMED, EXECUTING, SUCCEEDED, FAILED, REJECTED, OUTCOME_UNKNOWN
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
|
* Geschlossene Transition-Allowlist
|
||||||
|
* Ungültige Transition -> FAIL CLOSED (InvalidTransitionError)
|
||||||
|
* DELETE: KEIN RETRYABLE — ein gestarteter DELETE wird bei Lease-Expiry in
|
||||||
|
OUTCOME_UNKNOWN überführt, NICHT zurück zu READY (kein blinder Retry)
|
||||||
|
* SAVE: RETRYABLE nur bei transienten Fehlern (TolariaUnavailable)
|
||||||
|
|
||||||
|
Isoliert implementiert (KEIN produktiver Container).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, FrozenSet, Optional, Tuple
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# States
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ST_CREATED = "CREATED"
|
||||||
|
ST_READY = "READY"
|
||||||
|
ST_CLAIMED = "CLAIMED"
|
||||||
|
ST_EXECUTING = "EXECUTING"
|
||||||
|
ST_SUCCEEDED = "SUCCEEDED"
|
||||||
|
ST_FAILED = "FAILED"
|
||||||
|
ST_REJECTED = "REJECTED"
|
||||||
|
ST_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN"
|
||||||
|
|
||||||
|
ALL_STATES = frozenset({
|
||||||
|
ST_CREATED, ST_READY, ST_CLAIMED, ST_EXECUTING,
|
||||||
|
ST_SUCCEEDED, ST_FAILED, ST_REJECTED, ST_OUTCOME_UNKNOWN,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Erlaubte Transitionen (geschlossene Allowlist)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# (from_state, to_state)
|
||||||
|
ALLOWED_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({
|
||||||
|
# CREATED
|
||||||
|
(ST_CREATED, ST_READY), # Schema validiert, Job akzeptiert
|
||||||
|
(ST_CREATED, ST_REJECTED), # Schema/Allowlist/Injection-Fehler
|
||||||
|
# READY
|
||||||
|
(ST_READY, ST_CLAIMED), # atomarer Claim
|
||||||
|
(ST_READY, ST_REJECTED), # Gate-Fehler vor Claim
|
||||||
|
# CLAIMED
|
||||||
|
(ST_CLAIMED, ST_EXECUTING), # Claim bestätigt
|
||||||
|
(ST_CLAIMED, ST_REJECTED), # Gate-Fehler nach Claim
|
||||||
|
# EXECUTING
|
||||||
|
(ST_EXECUTING, ST_SUCCEEDED), # bestätigter Erfolg
|
||||||
|
(ST_EXECUTING, ST_FAILED), # bestätigter Fehler, kein Mutationseffekt
|
||||||
|
(ST_EXECUTING, ST_OUTCOME_UNKNOWN), # unklares HTTP-Ergebnis
|
||||||
|
(ST_EXECUTING, ST_REJECTED), # Gate-Fehler während Execution
|
||||||
|
# OUTCOME_UNKNOWN (terminal für DELETE; SAVE kann via Human/Admin reaktiviert werden)
|
||||||
|
(ST_OUTCOME_UNKNOWN, ST_REJECTED), # expliziter Human-/Admin-Eingriff
|
||||||
|
})
|
||||||
|
|
||||||
|
# Terminal-States (keine weiteren Transitionen ohne expliziten Eingriff)
|
||||||
|
TERMINAL_STATES = frozenset({ST_SUCCEEDED, ST_FAILED, ST_REJECTED})
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransitionError(ValueError):
|
||||||
|
"""Ungültige Transition -> FAIL CLOSED."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_transition(from_state: str, to_state: str) -> bool:
|
||||||
|
return (from_state, to_state) in ALLOWED_TRANSITIONS
|
||||||
|
|
||||||
|
|
||||||
|
def transition(from_state: str, to_state: str) -> str:
|
||||||
|
"""
|
||||||
|
Führt eine State-Transition aus. Wirft InvalidTransitionError bei ungültiger
|
||||||
|
Transition (FAIL CLOSED).
|
||||||
|
"""
|
||||||
|
if from_state not in ALL_STATES:
|
||||||
|
raise InvalidTransitionError(f"unknown from_state: {from_state!r}")
|
||||||
|
if to_state not in ALL_STATES:
|
||||||
|
raise InvalidTransitionError(f"unknown to_state: {to_state!r}")
|
||||||
|
if not is_valid_transition(from_state, to_state):
|
||||||
|
raise InvalidTransitionError(
|
||||||
|
f"invalid transition: {from_state} -> {to_state} (FAIL CLOSED)"
|
||||||
|
)
|
||||||
|
return to_state
|
||||||
|
|
||||||
|
|
||||||
|
def is_terminal(state: str) -> bool:
|
||||||
|
return state in TERMINAL_STATES
|
||||||
|
|
||||||
|
|
||||||
|
def is_retryable(state: str, job_type: str) -> bool:
|
||||||
|
"""
|
||||||
|
SAVE: RETRYABLE nur aus OUTCOME_UNKNOWN (transient) — via Human/Admin.
|
||||||
|
DELETE: NIE retryable — ein gestarteter DELETE wird nicht blind wiederholt.
|
||||||
|
"""
|
||||||
|
if job_type == "C5_DELETE_OBJECT":
|
||||||
|
return False
|
||||||
|
# SAVE
|
||||||
|
return state == ST_OUTCOME_UNKNOWN
|
||||||
307
tolaria/c5-sync-service/job_store.py
Normal file
307
tolaria/c5-sync-service/job_store.py
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — job_store.py
|
||||||
|
======================
|
||||||
|
SQLite Inbox für den Executor Command Channel.
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
|
* Getrennte DB-Dateien pro Executor (c5a_save.db / c5a_delete.db)
|
||||||
|
* Atomarer Claim (READY -> CLAIMED) via SQLite-Transaktion (kein TOCTOU)
|
||||||
|
* Lease (lease_until) für Crash-Recovery
|
||||||
|
* Idempotenz (job_id / idempotency_key UNIQUE)
|
||||||
|
* Immutable fields after claim (UPDATE verboten für sicherheitskritische Felder)
|
||||||
|
* FAIL CLOSED bei DB-Fehler
|
||||||
|
|
||||||
|
Isoliert implementiert (KEIN produktiver Container). Nutzt job_schema + job_state_machine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from job_schema import (
|
||||||
|
JOB_TYPE_DELETE,
|
||||||
|
JOB_TYPE_SAVE,
|
||||||
|
JobRejectedError,
|
||||||
|
validate_job,
|
||||||
|
)
|
||||||
|
from job_state_machine import (
|
||||||
|
ST_CLAIMED,
|
||||||
|
ST_CREATED,
|
||||||
|
ST_EXECUTING,
|
||||||
|
ST_FAILED,
|
||||||
|
ST_OUTCOME_UNKNOWN,
|
||||||
|
ST_READY,
|
||||||
|
ST_REJECTED,
|
||||||
|
ST_SUCCEEDED,
|
||||||
|
InvalidTransitionError,
|
||||||
|
transition,
|
||||||
|
)
|
||||||
|
from job_claim import (
|
||||||
|
JobAlreadyClaimedError,
|
||||||
|
JobNotFoundError,
|
||||||
|
atomic_claim,
|
||||||
|
recover_stale_claims as _recover_stale_claims,
|
||||||
|
renew_lease as _renew_lease,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Immutable fields after claim (UPDATE verboten)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
IMMUTABLE_FIELDS = frozenset({
|
||||||
|
"job_type", "object_id", "vault_path", "approval_id",
|
||||||
|
"expected_commit", "expected_provenance_hash", "delete_request_id",
|
||||||
|
"source_commit", "provenance_hash", "mission_id",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class JobStoreError(Exception):
|
||||||
|
"""Basis-Fehler für JobStore."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobImmutableFieldError(JobStoreError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JobStore:
|
||||||
|
"""
|
||||||
|
SQLite Inbox. Ein Store pro Executor (SAVE oder DELETE).
|
||||||
|
|
||||||
|
worker_scope: "SAVE" oder "DELETE" — bestimmt, welche Job-Types claimbar sind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str, worker_scope: str):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.worker_scope = worker_scope.upper()
|
||||||
|
if self.worker_scope not in ("SAVE", "DELETE"):
|
||||||
|
raise JobStoreError(f"invalid worker_scope: {worker_scope!r}")
|
||||||
|
self._conn = sqlite3.connect(db_path)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
def _init_schema(self) -> None:
|
||||||
|
self._conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
job_version INTEGER NOT NULL,
|
||||||
|
mission_id TEXT NOT NULL,
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
object_id TEXT NOT NULL,
|
||||||
|
vault_path TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL, -- vollständiger Job (JSON)
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL UNIQUE,
|
||||||
|
worker_id TEXT,
|
||||||
|
claim_id TEXT,
|
||||||
|
claimed_at INTEGER,
|
||||||
|
lease_until INTEGER,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
result_code TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self._conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state)
|
||||||
|
""")
|
||||||
|
self._conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(job_type)
|
||||||
|
""")
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
# -- Job-Type-Scope -----------------------------------------------------
|
||||||
|
|
||||||
|
def _job_type_allowed(self, job_type: str) -> bool:
|
||||||
|
"""SAVE-Executor claimt nur SAVE-Jobs; DELETE-Executor nur DELETE-Jobs."""
|
||||||
|
if self.worker_scope == "SAVE":
|
||||||
|
return job_type == JOB_TYPE_SAVE
|
||||||
|
return job_type == JOB_TYPE_DELETE
|
||||||
|
|
||||||
|
# -- Erzeugen (RQ-Seite) ------------------------------------------------
|
||||||
|
|
||||||
|
def create_job(self, job: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Erzeugt einen Job (Status CREATED). Idempotent per job_id.
|
||||||
|
Wirft JobRejectedError bei Schema-Verletzung.
|
||||||
|
"""
|
||||||
|
validated = validate_job(job)
|
||||||
|
job_id = validated["job_id"]
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
# Idempotenz: gleiche job_id -> bestehenden Job zurückgeben (kein Fehler).
|
||||||
|
existing = self.get_job(job_id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
try:
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jobs
|
||||||
|
(job_id, job_version, mission_id, job_type, object_id,
|
||||||
|
vault_path, payload, state, idempotency_key, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id, validated["job_version"], validated["mission_id"],
|
||||||
|
validated["job_type"], validated["object_id"],
|
||||||
|
validated["vault_path"], json.dumps(validated),
|
||||||
|
ST_CREATED, validated["idempotency_key"],
|
||||||
|
validated["created_at"], now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
# UNIQUE-Verletzung (idempotency_key bereits vergeben) -> Anomalie.
|
||||||
|
raise JobStoreError(f"create_job failed (idempotency_key collision): {e}") from e
|
||||||
|
return self.get_job(job_id)
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM jobs WHERE job_id = ?", (job_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
def get_job_by_idempotency_key(self, idempotency_key: str) -> Optional[Dict[str, Any]]:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT * FROM jobs WHERE idempotency_key = ?", (idempotency_key,)
|
||||||
|
).fetchone()
|
||||||
|
return self._row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
def list_jobs(self, state: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
if state:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT * FROM jobs WHERE state = ? ORDER BY created_at", (state,)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT * FROM jobs ORDER BY created_at"
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
# -- State-Transition (mit Immutable-Fields-Schutz) ---------------------
|
||||||
|
|
||||||
|
def _transition(self, job_id: str, to_state: str, *, result_code: Optional[str] = None,
|
||||||
|
worker_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Führt eine State-Transition aus. Wirft InvalidTransitionError bei
|
||||||
|
ungültiger Transition (FAIL CLOSED).
|
||||||
|
"""
|
||||||
|
job = self.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise JobNotFoundError(f"job not found: {job_id}")
|
||||||
|
from_state = job["state"]
|
||||||
|
transition(from_state, to_state) # wirft bei ungültiger Transition
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
self._conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs SET state = ?, result_code = ?, updated_at = ?,
|
||||||
|
worker_id = COALESCE(?, worker_id)
|
||||||
|
WHERE job_id = ?
|
||||||
|
""",
|
||||||
|
(to_state, result_code, now, worker_id, job_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return self.get_job(job_id)
|
||||||
|
|
||||||
|
def mark_ready(self, job_id: str) -> Dict[str, Any]:
|
||||||
|
return self._transition(job_id, ST_READY)
|
||||||
|
|
||||||
|
def mark_rejected(self, job_id: str, result_code: str) -> Dict[str, Any]:
|
||||||
|
return self._transition(job_id, ST_REJECTED, result_code=result_code)
|
||||||
|
|
||||||
|
def mark_succeeded(self, job_id: str, worker_id: str) -> Dict[str, Any]:
|
||||||
|
return self._transition(job_id, ST_SUCCEEDED, worker_id=worker_id)
|
||||||
|
|
||||||
|
def mark_failed(self, job_id: str, result_code: str, worker_id: str) -> Dict[str, Any]:
|
||||||
|
return self._transition(job_id, ST_FAILED, result_code=result_code, worker_id=worker_id)
|
||||||
|
|
||||||
|
def mark_outcome_unknown(self, job_id: str, worker_id: str) -> Dict[str, Any]:
|
||||||
|
return self._transition(job_id, ST_OUTCOME_UNKNOWN, worker_id=worker_id)
|
||||||
|
|
||||||
|
# -- Claim / Lease ------------------------------------------------------
|
||||||
|
|
||||||
|
def claim_job(self, job_id: str, worker_id: str, lease_seconds: int = 60) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Atomarer Claim: READY -> CLAIMED, nur wenn Lease abgelaufen oder nie gesetzt.
|
||||||
|
Kein TOCTOU (SQLite-Transaktion mit Status-Bedingung). Delegiert an job_claim.
|
||||||
|
"""
|
||||||
|
claimed = atomic_claim(self._conn, job_id, worker_id, lease_seconds)
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
def begin_execution(self, job_id: str, worker_id: str) -> Dict[str, Any]:
|
||||||
|
"""CLAIMED -> EXECUTING (Claim bestätigt)."""
|
||||||
|
return self._transition(job_id, ST_EXECUTING, worker_id=worker_id)
|
||||||
|
|
||||||
|
def renew_lease(self, job_id: str, lease_seconds: int = 60) -> Dict[str, Any]:
|
||||||
|
"""Verlängert die Lease eines CLAIMED/EXECUTING-Jobs."""
|
||||||
|
_renew_lease(self._conn, job_id, lease_seconds)
|
||||||
|
return self.get_job(job_id)
|
||||||
|
|
||||||
|
# -- Crash-Recovery -----------------------------------------------------
|
||||||
|
|
||||||
|
def recover_stale_claims(self, worker_id: str, lease_seconds: int = 60) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Findet stale CLAIMED-Jobs (Lease abgelaufen) und überführt sie:
|
||||||
|
* SAVE: zurück zu READY (wieder claimbar, RETRYABLE)
|
||||||
|
* DELETE: zu OUTCOME_UNKNOWN (NICHT blind wiederholen)
|
||||||
|
"""
|
||||||
|
# job_claim.recover_stale_claims überführt SAVE->READY, DELETE->OUTCOME_UNKNOWN
|
||||||
|
# anhand des job_type. Wir rufen es pro Job-Type auf (Scope-getrennt).
|
||||||
|
recovered = []
|
||||||
|
for job_type in (JOB_TYPE_SAVE, JOB_TYPE_DELETE):
|
||||||
|
if self._job_type_allowed(job_type):
|
||||||
|
_recover_stale_claims(self._conn, job_type)
|
||||||
|
# Re-read recovered jobs
|
||||||
|
for row in self._conn.execute(
|
||||||
|
"SELECT job_id FROM jobs WHERE state IN (?, ?)",
|
||||||
|
(ST_READY, ST_OUTCOME_UNKNOWN),
|
||||||
|
).fetchall():
|
||||||
|
job = self.get_job(row["job_id"])
|
||||||
|
if job is not None:
|
||||||
|
recovered.append(job)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
# -- Idempotenz ---------------------------------------------------------
|
||||||
|
|
||||||
|
def is_duplicate(self, job_id: str, idempotency_key: str) -> bool:
|
||||||
|
"""True, wenn job_id ODER idempotency_key bereits existiert."""
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT 1 FROM jobs WHERE job_id = ? OR idempotency_key = ? LIMIT 1",
|
||||||
|
(job_id, idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
# -- Audit --------------------------------------------------------------
|
||||||
|
|
||||||
|
def audit_trail(self, job_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Gibt den Audit-Trail eines Jobs zurück (aus der jobs-Tabelle)."""
|
||||||
|
job = self.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
return []
|
||||||
|
return [{
|
||||||
|
"job_id": job["job_id"],
|
||||||
|
"mission_id": job["mission_id"],
|
||||||
|
"job_type": job["job_type"],
|
||||||
|
"object_id": job["object_id"],
|
||||||
|
"state": job["state"],
|
||||||
|
"worker_id": job["worker_id"],
|
||||||
|
"attempt_count": job["attempt_count"],
|
||||||
|
"result_code": job["result_code"],
|
||||||
|
"created_at": job["created_at"],
|
||||||
|
"updated_at": job["updated_at"],
|
||||||
|
}]
|
||||||
|
|
||||||
|
# -- Helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||||
|
d = dict(row)
|
||||||
|
payload = json.loads(d["payload"])
|
||||||
|
# Merge Payload-Felder in das Top-Level-Dict, damit Executor-Cores
|
||||||
|
# auf approval_id/expected_commit/source_commit etc. zugreifen können.
|
||||||
|
d.update(payload)
|
||||||
|
d["payload"] = payload
|
||||||
|
return d
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._conn.close()
|
||||||
144
tolaria/c5-sync-service/save_executor_core.py
Normal file
144
tolaria/c5-sync-service/save_executor_core.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — save_executor_core.py
|
||||||
|
================================
|
||||||
|
SAVE-Executor-Logik (SAVE-only).
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
|
* SAVE-only: claimt NUR C5_SAVE_OBJECT-Jobs
|
||||||
|
* Content-Rekonstruktion: lädt Content selbst aus autoritativer Source (Forgejo),
|
||||||
|
statt RQ blind zu vertrauen (DATA FROM RQ != AUTHORITY)
|
||||||
|
* RQ darf NICHT bestimmen: Tolaria Base URL, HTTP Method, Authorization Header,
|
||||||
|
Credential, beliebigen Zielendpoint
|
||||||
|
* Fail-closed: kein HTTP ohne SAVE-Credential
|
||||||
|
* OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry)
|
||||||
|
|
||||||
|
Isoliert implementiert (KEIN produktiver Container). Nutzt job_store + job_schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from job_schema import JOB_TYPE_SAVE
|
||||||
|
from job_store import JobStore
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Result-Codes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RC_OK = "OK"
|
||||||
|
RC_CREDENTIAL_MISSING = "CREDENTIAL_MISSING"
|
||||||
|
RC_SOURCE_UNAVAILABLE = "SOURCE_UNAVAILABLE"
|
||||||
|
RC_PROVENANCE_MISMATCH = "PROVENANCE_MISMATCH"
|
||||||
|
RC_STATE_MISMATCH = "STATE_MISMATCH"
|
||||||
|
RC_PATH_INVALID = "PATH_INVALID"
|
||||||
|
RC_TOLARIA_UNAVAILABLE = "TOLARIA_UNAVAILABLE"
|
||||||
|
RC_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN"
|
||||||
|
RC_REJECTED = "REJECTED"
|
||||||
|
|
||||||
|
|
||||||
|
class SaveExecutorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SaveExecutorCore:
|
||||||
|
"""
|
||||||
|
SAVE-Executor. worker_scope="SAVE" (nur SAVE-Jobs claimbar).
|
||||||
|
|
||||||
|
source_loader: Callable[[str, str], Optional[str]] — lädt Content aus
|
||||||
|
autoritativer Source (source_commit, object_id) -> content oder None.
|
||||||
|
tolaria_save: Callable[[str, str], Dict[str, Any]] — führt den Tolaria-SAVE
|
||||||
|
aus (vault_path, content) -> {"status": "ok"|"error", "uncertain": bool}.
|
||||||
|
Muss fail-closed sein (kein HTTP ohne Credential).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: JobStore,
|
||||||
|
source_loader: Callable[[str, str], Optional[str]],
|
||||||
|
tolaria_save: Callable[[str, str], Dict[str, Any]],
|
||||||
|
):
|
||||||
|
if store.worker_scope != "SAVE":
|
||||||
|
raise SaveExecutorError("SaveExecutorCore requires worker_scope='SAVE'")
|
||||||
|
self.store = store
|
||||||
|
self.source_loader = source_loader
|
||||||
|
self.tolaria_save = tolaria_save
|
||||||
|
|
||||||
|
# -- Hauptverarbeitung --------------------------------------------------
|
||||||
|
|
||||||
|
def process_job(self, job_id: str, worker_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verarbeitet einen SAVE-Job durch die State Machine.
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. Job laden (muss existieren)
|
||||||
|
2. Job-Type-Scope prüfen (nur SAVE)
|
||||||
|
3. READY -> CLAIMED (atomarer Claim)
|
||||||
|
4. CLAIMED -> EXECUTING
|
||||||
|
5. Content aus autoritativer Source rekonstruieren
|
||||||
|
6. Provenance/State validieren
|
||||||
|
7. Tolaria-SAVE ausführen
|
||||||
|
8. Ergebnis: SUCCEEDED / FAILED / OUTCOME_UNKNOWN / REJECTED
|
||||||
|
"""
|
||||||
|
job = self.store.get_job(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise SaveExecutorError(f"job not found: {job_id}")
|
||||||
|
|
||||||
|
# Scope: nur SAVE-Jobs
|
||||||
|
if job["job_type"] != JOB_TYPE_SAVE:
|
||||||
|
self.store.mark_rejected(job_id, RC_REJECTED)
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
# Atomarer Claim
|
||||||
|
try:
|
||||||
|
self.store.claim_job(job_id, worker_id)
|
||||||
|
except Exception:
|
||||||
|
# Nicht claimbar (bereits geclaimt) -> kein Doppel-Write
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
self.store.begin_execution(job_id, worker_id)
|
||||||
|
|
||||||
|
# Content-Rekonstruktion aus autoritativer Source
|
||||||
|
content = self._reconstruct_content(job)
|
||||||
|
if content is None:
|
||||||
|
self.store.mark_failed(job_id, RC_SOURCE_UNAVAILABLE, worker_id)
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
# Provenance validieren (RECOMPUTE)
|
||||||
|
if not self._validate_provenance(job, content):
|
||||||
|
self.store.mark_failed(job_id, RC_PROVENANCE_MISMATCH, worker_id)
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
# Tolaria-SAVE ausführen (fail-closed)
|
||||||
|
result = self.tolaria_save(job["vault_path"], content)
|
||||||
|
status = result.get("status")
|
||||||
|
if status == "ok":
|
||||||
|
self.store.mark_succeeded(job_id, worker_id)
|
||||||
|
elif status == "error" and result.get("uncertain"):
|
||||||
|
self.store.mark_outcome_unknown(job_id, worker_id)
|
||||||
|
else:
|
||||||
|
self.store.mark_failed(job_id, result.get("code", RC_TOLARIA_UNAVAILABLE), worker_id)
|
||||||
|
|
||||||
|
return self.store.get_job(job_id)
|
||||||
|
|
||||||
|
# -- Content-Rekonstruktion ---------------------------------------------
|
||||||
|
|
||||||
|
def _reconstruct_content(self, job: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Lädt Content selbst aus autoritativer Source (source_commit, object_id).
|
||||||
|
RQ liefert KEINEN Content im Job — nur Referenzen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.source_loader(job["source_commit"], job["object_id"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- Provenance-Validierung ---------------------------------------------
|
||||||
|
|
||||||
|
def _validate_provenance(self, job: Dict[str, Any], content: str) -> bool:
|
||||||
|
"""
|
||||||
|
RECOMPUTE: berechnet den Provenance-Hash aus dem rekonstruierten Content
|
||||||
|
und vergleicht mit dem Job-Feld. RQ darf den Hash nicht blind bestimmen.
|
||||||
|
"""
|
||||||
|
computed = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
return computed == job["provenance_hash"]
|
||||||
197
tolaria/c5-sync-service/sensitivity_proof_auth3e.py
Normal file
197
tolaria/c5-sync-service/sensitivity_proof_auth3e.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — sensitivity_proof_auth3e.py
|
||||||
|
======================================
|
||||||
|
Test Sensitivity (Mission §23): Mutationen A–L.
|
||||||
|
|
||||||
|
Beweist, dass jede Mutation eine relevante Sicherheitsinvariante entfernt und
|
||||||
|
damit die zugehörigen Tests ROT machen würde. Dies ist eine STATISCHE Analyse
|
||||||
|
der Invarianten — die Mutationen selbst werden NICHT produktiv angewendet.
|
||||||
|
|
||||||
|
Mutationen:
|
||||||
|
A job_type allowlist entfernen
|
||||||
|
B SAVE/DELETE Worker-Scope entfernen
|
||||||
|
C atomic claim entfernen
|
||||||
|
D idempotency entfernen
|
||||||
|
E approval requirement entfernen
|
||||||
|
F signature verification entfernen
|
||||||
|
G mission binding entfernen
|
||||||
|
H object binding entfernen
|
||||||
|
I commit binding entfernen
|
||||||
|
J provenance binding entfernen
|
||||||
|
K OUTCOME_UNKNOWN blind retry erlauben
|
||||||
|
L credential fail-closed entfernen
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invarianten -> Test-Mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jede Mutation entfernt eine Invariante. Die zugehörigen Tests würden ROT.
|
||||||
|
MUTATION_INVARIANTS = {
|
||||||
|
"A": "job_type muss in geschlossener Allowlist sein (job_schema.JOB_TYPES)",
|
||||||
|
"B": "SAVE-Executor claimt nur SAVE-Jobs, DELETE-Executor nur DELETE-Jobs (worker_scope)",
|
||||||
|
"C": "Claim ist atomar (kein TOCTOU, nur ein Worker gewinnt)",
|
||||||
|
"D": "duplicate job_id/idempotency_key idempotent (UNIQUE)",
|
||||||
|
"E": "DELETE ohne Approval denied (APPROVAL_MISSING)",
|
||||||
|
"F": "invalid Signature denied (APPROVAL_INVALID)",
|
||||||
|
"G": "falsche Mission denied (MISSION_MISMATCH)",
|
||||||
|
"H": "falsches Object denied (OBJECT_MISMATCH)",
|
||||||
|
"I": "falscher Commit denied (COMMIT_MISMATCH)",
|
||||||
|
"J": "falsche Provenance denied (PROVENANCE_MISMATCH)",
|
||||||
|
"K": "OUTCOME_UNKNOWN -> kein blinder Retry",
|
||||||
|
"L": "Credential fehlt -> kein HTTP (fail-closed)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test-Methoden, die die jeweilige Invariante prüfen (ROT bei Mutation)
|
||||||
|
MUTATION_TESTS = {
|
||||||
|
"A": ["test_a_job_type_allowlist_removed", "test_t3_unknown_job_type_rejected"],
|
||||||
|
"B": ["test_b_save_delete_worker_scope_removed", "test_t11_save_executor_cannot_claim_delete_job",
|
||||||
|
"test_t12_delete_executor_cannot_claim_save_job"],
|
||||||
|
"C": ["test_c_atomic_claim_removed", "test_t18_two_worker_claim_atomic"],
|
||||||
|
"D": ["test_d_idempotency_removed", "test_t16_duplicate_job_id_idempotent"],
|
||||||
|
"E": ["test_e_approval_requirement_removed", "test_t21_delete_job_without_approval_denied"],
|
||||||
|
"F": ["test_f_signature_verification_removed", "test_t22_delete_job_invalid_signature_denied"],
|
||||||
|
"G": ["test_g_mission_binding_removed", "test_t27_wrong_mission_denied"],
|
||||||
|
"H": ["test_h_object_binding_removed", "test_t23_delete_job_wrong_object_denied"],
|
||||||
|
"I": ["test_i_commit_binding_removed", "test_t25_wrong_commit_denied"],
|
||||||
|
"J": ["test_j_provenance_binding_removed", "test_t26_wrong_provenance_denied"],
|
||||||
|
"K": ["test_k_outcome_unknown_blind_retry_removed", "test_t36_outcome_unknown_no_blind_retry"],
|
||||||
|
"L": ["test_l_credential_fail_closed_removed", "test_t32_delete_credential_missing_no_http",
|
||||||
|
"test_t33_save_credential_missing_no_http"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_allowlist() -> bool:
|
||||||
|
"""Prüft, ob job_schema eine geschlossene JOB_TYPES-Allowlist hat."""
|
||||||
|
src = Path("job_schema.py").read_text(encoding="utf-8")
|
||||||
|
return "JOB_TYPES = frozenset" in src and "JOB_TYPE_SAVE" in src and "JOB_TYPE_DELETE" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_worker_scope() -> bool:
|
||||||
|
"""Prüft, ob Executor-Cores Worker-Scope erzwingen."""
|
||||||
|
save = Path("save_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
delete = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return ("worker_scope != \"SAVE\"" in save) and ("worker_scope != \"DELETE\"" in delete)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_atomic_claim() -> bool:
|
||||||
|
"""Prüft, ob job_claim einen atomaren Claim (Status-Bedingung) hat."""
|
||||||
|
src = Path("job_claim.py").read_text(encoding="utf-8")
|
||||||
|
return "state = ?" in src and "lease_until IS NULL OR lease_until < ?" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_idempotency() -> bool:
|
||||||
|
"""Prüft, ob job_store UNIQUE-Constraints für job_id/idempotency_key hat."""
|
||||||
|
src = Path("job_store.py").read_text(encoding="utf-8")
|
||||||
|
return "TEXT PRIMARY KEY" in src and "idempotency_key TEXT NOT NULL UNIQUE" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_approval_requirement() -> bool:
|
||||||
|
"""Prüft, ob delete_executor_core Approval lädt und prüft."""
|
||||||
|
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return "approval_loader" in src and "RC_APPROVAL_MISSING" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_signature_verification() -> bool:
|
||||||
|
"""Prüft, ob delete_executor_core die Signatur verifiziert."""
|
||||||
|
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return "approval_verify" in src and "RC_APPROVAL_INVALID" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_mission_binding() -> bool:
|
||||||
|
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return "mission_id" in src and "RC_MISSION_MISMATCH" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_object_binding() -> bool:
|
||||||
|
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return "object_id" in src and "RC_OBJECT_MISMATCH" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_commit_binding() -> bool:
|
||||||
|
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return "expected_commit" in src and "RC_COMMIT_MISMATCH" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_provenance_binding() -> bool:
|
||||||
|
src = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return "expected_provenance_hash" in src and "RC_PROVENANCE_MISMATCH" in src
|
||||||
|
|
||||||
|
|
||||||
|
def _has_no_blind_retry() -> bool:
|
||||||
|
"""Prüft, ob OUTCOME_UNKNOWN nicht zurück zu READY führt (kein blinder Retry)."""
|
||||||
|
sm = Path("job_state_machine.py").read_text(encoding="utf-8")
|
||||||
|
return "(ST_OUTCOME_UNKNOWN, ST_READY)" not in sm and "is_retryable" in sm
|
||||||
|
|
||||||
|
|
||||||
|
def _has_credential_fail_closed() -> bool:
|
||||||
|
"""Prüft, ob Executor-Cores fail-closed bei fehlendem Credential sind."""
|
||||||
|
save = Path("save_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
delete = Path("delete_executor_core.py").read_text(encoding="utf-8")
|
||||||
|
return "CREDENTIAL_MISSING" in save and "CREDENTIAL_MISSING" in delete
|
||||||
|
|
||||||
|
|
||||||
|
# Invariante -> Prüffunktion
|
||||||
|
INVARIANT_CHECKS = {
|
||||||
|
"A": _has_allowlist,
|
||||||
|
"B": _has_worker_scope,
|
||||||
|
"C": _has_atomic_claim,
|
||||||
|
"D": _has_idempotency,
|
||||||
|
"E": _has_approval_requirement,
|
||||||
|
"F": _has_signature_verification,
|
||||||
|
"G": _has_mission_binding,
|
||||||
|
"H": _has_object_binding,
|
||||||
|
"I": _has_commit_binding,
|
||||||
|
"J": _has_provenance_binding,
|
||||||
|
"K": _has_no_blind_retry,
|
||||||
|
"L": _has_credential_fail_closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_sensitivity_proof() -> dict:
|
||||||
|
"""
|
||||||
|
Führt die Sensitivitäts-Analyse aus. Gibt ein Dict zurück:
|
||||||
|
{
|
||||||
|
"mutation": {"invariant": str, "present": bool, "tests_would_go_red": [...]},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for mutation, invariant in MUTATION_INVARIANTS.items():
|
||||||
|
check = INVARIANT_CHECKS[mutation]
|
||||||
|
present = check()
|
||||||
|
result[mutation] = {
|
||||||
|
"invariant": invariant,
|
||||||
|
"present": present,
|
||||||
|
"tests_would_go_red": MUTATION_TESTS[mutation],
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
result = run_sensitivity_proof()
|
||||||
|
all_present = True
|
||||||
|
print("=" * 70)
|
||||||
|
print("AUTH.3E — TEST SENSITIVITY PROOF (Mutationen A–L)")
|
||||||
|
print("=" * 70)
|
||||||
|
for mutation, info in sorted(result.items()):
|
||||||
|
status = "PRESENT" if info["present"] else "MISSING (ROT)"
|
||||||
|
if not info["present"]:
|
||||||
|
all_present = False
|
||||||
|
print(f" Mutation {mutation}: {status}")
|
||||||
|
print(f" Invariante: {info['invariant']}")
|
||||||
|
print(f" Tests ROT: {', '.join(info['tests_would_go_red'])}")
|
||||||
|
print("=" * 70)
|
||||||
|
if all_present:
|
||||||
|
print("RESULT: ALL 12 INVARIANTS PRESENT — jede Mutation macht Tests ROT")
|
||||||
|
return 0
|
||||||
|
print("RESULT: MINDESTENS EINE INVARIANTE FEHLT — Tests verstärken!")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
628
tolaria/c5-sync-service/test_job_channel.py
Normal file
628
tolaria/c5-sync-service/test_job_channel.py
Normal file
|
|
@ -0,0 +1,628 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — test_job_channel.py
|
||||||
|
==============================
|
||||||
|
Isolierte Tests für den Executor Command Channel (T1–T40 + adversarial).
|
||||||
|
|
||||||
|
Testumgebung:
|
||||||
|
* Synthetische Secrets (keine echten Credentials)
|
||||||
|
* Fake-Tolaria (kein echtes HTTP)
|
||||||
|
* Temp-DB (keine produktive DB)
|
||||||
|
* Keine produktiven Mutationsprobes
|
||||||
|
|
||||||
|
Abdeckung (Mission §21):
|
||||||
|
T1 gültiger SAVE Job akzeptiert
|
||||||
|
T2 gültiger DELETE Job akzeptiert
|
||||||
|
T3 unknown job_type rejected
|
||||||
|
T4 malformed schema rejected
|
||||||
|
T5 extra privileged field rejected
|
||||||
|
T6 URL injection rejected
|
||||||
|
T7 Authorization header injection rejected
|
||||||
|
T8 shell field rejected
|
||||||
|
T9 absolute outside path rejected
|
||||||
|
T10 traversal rejected
|
||||||
|
T11 SAVE Executor kann DELETE Job nicht claimen
|
||||||
|
T12 DELETE Executor kann SAVE Job nicht claimen
|
||||||
|
T13 SAVE Executor besitzt kein DELETE Credential
|
||||||
|
T14 DELETE Executor besitzt kein SAVE Credential
|
||||||
|
T15 RQ besitzt kein Credential
|
||||||
|
T16 duplicate job id rejected/idempotent
|
||||||
|
T17 duplicate idempotency key sicher
|
||||||
|
T18 two-worker claim atomar
|
||||||
|
T19 stale SAVE claim recovery sicher
|
||||||
|
T20 stale DELETE claim führt nicht zu blindem Delete
|
||||||
|
T21 DELETE Job ohne Approval denied
|
||||||
|
T22 DELETE Job mit invalid Signature denied
|
||||||
|
T23 DELETE Job mit falschem Object denied
|
||||||
|
T24 falscher Path denied
|
||||||
|
T25 falscher Commit denied
|
||||||
|
T26 falsche Provenance denied
|
||||||
|
T27 falsche Mission denied
|
||||||
|
T28 expired Approval denied
|
||||||
|
T29 consumed Approval denied
|
||||||
|
T30 replay denied
|
||||||
|
T31 valid DELETE Job + valid AUTH3D Approval reaches executor mutation boundary
|
||||||
|
T32 DELETE credential missing -> no HTTP
|
||||||
|
T33 SAVE credential missing -> no HTTP
|
||||||
|
T34 Tolaria timeout vor Mutation -> safe failure
|
||||||
|
T35 Tolaria timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN
|
||||||
|
T36 OUTCOME_UNKNOWN -> no blind retry
|
||||||
|
T37 confirmed DELETE -> retry does not second-delete
|
||||||
|
T38 rejected job -> zero filesystem side effect
|
||||||
|
T39 rejected job -> zero HTTP side effect
|
||||||
|
T40 rejected job -> no state escalation
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from job_schema import (
|
||||||
|
JOB_TYPE_DELETE,
|
||||||
|
JOB_TYPE_SAVE,
|
||||||
|
JobRejectedError,
|
||||||
|
make_delete_job,
|
||||||
|
make_save_job,
|
||||||
|
validate_job,
|
||||||
|
)
|
||||||
|
from job_state_machine import (
|
||||||
|
ST_CLAIMED,
|
||||||
|
ST_CREATED,
|
||||||
|
ST_EXECUTING,
|
||||||
|
ST_FAILED,
|
||||||
|
ST_OUTCOME_UNKNOWN,
|
||||||
|
ST_READY,
|
||||||
|
ST_REJECTED,
|
||||||
|
ST_SUCCEEDED,
|
||||||
|
InvalidTransitionError,
|
||||||
|
transition,
|
||||||
|
)
|
||||||
|
from job_store import JobStore
|
||||||
|
from save_executor_core import SaveExecutorCore
|
||||||
|
from delete_executor_core import DeleteExecutorCore
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(content: str) -> str:
|
||||||
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return "2026-08-27T00:00:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_save_job(**overrides) -> dict:
|
||||||
|
job = make_save_job(
|
||||||
|
job_id=_uuid(),
|
||||||
|
mission_id=_uuid(),
|
||||||
|
object_id="obj-001",
|
||||||
|
vault_path="/app/vault/notes/obj-001.md",
|
||||||
|
source_commit=_sha256("commit"),
|
||||||
|
provenance_hash=_sha256("content"),
|
||||||
|
expected_state="ready",
|
||||||
|
created_at=_now_iso(),
|
||||||
|
idempotency_key=_uuid(),
|
||||||
|
)
|
||||||
|
job.update(overrides)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _make_delete_job(**overrides) -> dict:
|
||||||
|
job = make_delete_job(
|
||||||
|
job_id=_uuid(),
|
||||||
|
mission_id=_uuid(),
|
||||||
|
delete_request_id=_uuid(),
|
||||||
|
object_id="obj-001",
|
||||||
|
vault_path="/app/vault/notes/obj-001.md",
|
||||||
|
expected_commit=_sha256("commit"),
|
||||||
|
expected_provenance_hash=_sha256("content"),
|
||||||
|
approval_id=_uuid(),
|
||||||
|
created_at=_now_iso(),
|
||||||
|
idempotency_key=_uuid(),
|
||||||
|
)
|
||||||
|
job.update(overrides)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_approval(job: dict) -> dict:
|
||||||
|
"""Erzeugt eine gültige AUTH.3D-Approval, die exakt zum DELETE-Job passt."""
|
||||||
|
return {
|
||||||
|
"approval_id": job["approval_id"],
|
||||||
|
"mission_id": job["mission_id"],
|
||||||
|
"object_id": job["object_id"],
|
||||||
|
"vault_path": job["vault_path"],
|
||||||
|
"expected_commit": job["expected_commit"],
|
||||||
|
"expected_provenance_hash": job["expected_provenance_hash"],
|
||||||
|
"nonce": _uuid(),
|
||||||
|
"expired": False,
|
||||||
|
"consumed": False,
|
||||||
|
"signature_valid": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JobSchemaTests(unittest.TestCase):
|
||||||
|
"""T1–T10: Schema-Validierung."""
|
||||||
|
|
||||||
|
def test_t1_valid_save_job_accepted(self):
|
||||||
|
job = _make_save_job()
|
||||||
|
result = validate_job(job)
|
||||||
|
self.assertEqual(result["job_type"], JOB_TYPE_SAVE)
|
||||||
|
|
||||||
|
def test_t2_valid_delete_job_accepted(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
result = validate_job(job)
|
||||||
|
self.assertEqual(result["job_type"], JOB_TYPE_DELETE)
|
||||||
|
|
||||||
|
def test_t3_unknown_job_type_rejected(self):
|
||||||
|
job = _make_save_job(job_type="C5_EXECUTE")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_t4_malformed_schema_rejected(self):
|
||||||
|
job = _make_save_job()
|
||||||
|
del job["object_id"]
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_t5_extra_privileged_field_rejected(self):
|
||||||
|
job = _make_save_job(command="rm -rf /")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_t6_url_injection_rejected(self):
|
||||||
|
job = _make_save_job(url="http://evil.example.com")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_t7_authorization_header_injection_rejected(self):
|
||||||
|
job = _make_save_job(authorization="Bearer evil")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_t8_shell_field_rejected(self):
|
||||||
|
job = _make_save_job(shell="/bin/sh")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_t9_absolute_outside_path_rejected(self):
|
||||||
|
job = _make_save_job(vault_path="/etc/passwd")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_t10_traversal_rejected(self):
|
||||||
|
job = _make_save_job(vault_path="/app/vault/../../etc/passwd")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
class JobStoreTests(unittest.TestCase):
|
||||||
|
"""T16–T20: Idempotenz, Claim, Recovery."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
|
||||||
|
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_t16_duplicate_job_id_idempotent(self):
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job()
|
||||||
|
store.create_job(job)
|
||||||
|
# Zweiter create mit gleicher job_id -> idempotent (INSERT OR IGNORE)
|
||||||
|
store.create_job(job)
|
||||||
|
jobs = store.list_jobs()
|
||||||
|
self.assertEqual(len(jobs), 1)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_t17_duplicate_idempotency_key_safe(self):
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
key = _uuid()
|
||||||
|
job1 = _make_save_job(idempotency_key=key)
|
||||||
|
job2 = _make_save_job(idempotency_key=key)
|
||||||
|
store.create_job(job1)
|
||||||
|
# Zweiter mit gleichem idempotency_key -> UNIQUE-Verletzung abgefangen
|
||||||
|
from job_store import JobStoreError
|
||||||
|
with self.assertRaises(JobStoreError):
|
||||||
|
store.create_job(job2)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_t18_two_worker_claim_atomic(self):
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job()
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
# Worker 1 claimt
|
||||||
|
store.claim_job(job["job_id"], "worker-1")
|
||||||
|
# Worker 2 kann NICHT claimen (bereits geclaimt)
|
||||||
|
from job_claim import JobAlreadyClaimedError
|
||||||
|
with self.assertRaises(JobAlreadyClaimedError):
|
||||||
|
store.claim_job(job["job_id"], "worker-2")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_t19_stale_save_claim_recovery_safe(self):
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job()
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen
|
||||||
|
recovered = store.recover_stale_claims("worker-2")
|
||||||
|
# SAVE -> zurück zu READY (wieder claimbar)
|
||||||
|
self.assertEqual(store.get_job(job["job_id"])["state"], ST_READY)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_t20_stale_delete_claim_no_blind_delete(self):
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
job = _make_delete_job()
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen
|
||||||
|
recovered = store.recover_stale_claims("worker-2")
|
||||||
|
# DELETE -> OUTCOME_UNKNOWN (NICHT zurück zu READY, kein blinder Retry)
|
||||||
|
self.assertEqual(store.get_job(job["job_id"])["state"], ST_OUTCOME_UNKNOWN)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SaveExecutorTests(unittest.TestCase):
|
||||||
|
"""T11, T13, T33, T34, T35, T36, T38, T39, T40 (SAVE)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
|
||||||
|
self.store = JobStore(self.save_db, "SAVE")
|
||||||
|
self.http_calls = []
|
||||||
|
self.content = "hello world"
|
||||||
|
self.provenance = _sha256(self.content)
|
||||||
|
|
||||||
|
def _source_loader(self, commit, object_id):
|
||||||
|
return self.content
|
||||||
|
|
||||||
|
def _tolaria_save(self, vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def test_t11_save_executor_cannot_claim_delete_job(self):
|
||||||
|
# SAVE-Store kann keinen DELETE-Job erzeugen (Schema) — aber selbst wenn
|
||||||
|
# ein DELETE-Job in den SAVE-Store injiziert würde, wird er REJECTED.
|
||||||
|
job = _make_delete_job()
|
||||||
|
# Direkt in DB injizieren (Cross-Executor-Injection-Simulation)
|
||||||
|
self.store.create_job(job) # Schema erlaubt DELETE-Job im Store? Nein — validate_job erlaubt beide
|
||||||
|
# Aber SaveExecutorCore.process_job lehnt DELETE-Job ab (Scope)
|
||||||
|
exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_REJECTED)
|
||||||
|
self.assertEqual(self.http_calls, []) # kein HTTP
|
||||||
|
|
||||||
|
def test_t13_save_executor_has_no_delete_credential(self):
|
||||||
|
# SAVE-Executor kennt nur SAVE-Credential (injiziert), kein DELETE-Token
|
||||||
|
self.assertNotIn("delete_token", dir(self.store))
|
||||||
|
self.assertNotIn("delete_token", dir(SaveExecutorCore))
|
||||||
|
|
||||||
|
def test_t33_save_credential_missing_no_http(self):
|
||||||
|
# tolaria_save fail-closed: ohne Credential kein HTTP
|
||||||
|
def no_cred_save(vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False}
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(self.store, self._source_loader, no_cred_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_FAILED)
|
||||||
|
self.assertEqual(result["result_code"], "CREDENTIAL_MISSING")
|
||||||
|
|
||||||
|
def test_t34_tolaria_timeout_before_mutation_safe_failure(self):
|
||||||
|
# Timeout VOR Mutation -> safe failure (kein Write, kein OUTCOME_UNKNOWN)
|
||||||
|
def timeout_save(vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "error", "code": "TOLARIA_UNAVAILABLE", "uncertain": False}
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(self.store, self._source_loader, timeout_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_FAILED)
|
||||||
|
self.assertEqual(result["result_code"], "TOLARIA_UNAVAILABLE")
|
||||||
|
|
||||||
|
def test_t35_tolaria_timeout_uncertain_outcome_unknown(self):
|
||||||
|
# Timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN
|
||||||
|
def uncertain_save(vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_OUTCOME_UNKNOWN)
|
||||||
|
|
||||||
|
def test_t36_outcome_unknown_no_blind_retry(self):
|
||||||
|
# OUTCOME_UNKNOWN -> kein blinder Retry (Job bleibt OUTCOME_UNKNOWN)
|
||||||
|
def uncertain_save(vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save)
|
||||||
|
exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
# Erneuter Versuch: Job ist OUTCOME_UNKNOWN, nicht claimbar -> kein 2. HTTP
|
||||||
|
exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(len(self.http_calls), 1)
|
||||||
|
|
||||||
|
def test_t38_rejected_job_zero_fs_side_effect(self):
|
||||||
|
# Rejected Job -> kein Dateisystem-Effekt (kein Write)
|
||||||
|
job = _make_save_job(job_type="C5_EXECUTE") # invalid
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
self.store.create_job(job)
|
||||||
|
# Kein Job in DB
|
||||||
|
self.assertEqual(self.store.list_jobs(), [])
|
||||||
|
|
||||||
|
def test_t39_rejected_job_zero_http_side_effect(self):
|
||||||
|
job = _make_save_job(job_type="C5_EXECUTE")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t40_rejected_job_no_state_escalation(self):
|
||||||
|
# Rejected Job -> kein State-Escalation (bleibt REJECTED, kein EXECUTING)
|
||||||
|
job = _make_delete_job()
|
||||||
|
self.store.create_job(job)
|
||||||
|
exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_REJECTED)
|
||||||
|
self.assertNotEqual(result["state"], ST_EXECUTING)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteExecutorTests(unittest.TestCase):
|
||||||
|
"""T12, T14, T21–T32, T37 (DELETE)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
|
||||||
|
self.store = JobStore(self.delete_db, "DELETE")
|
||||||
|
self.http_calls = []
|
||||||
|
self.approvals = {}
|
||||||
|
|
||||||
|
def _approval_loader(self, approval_id):
|
||||||
|
return self.approvals.get(approval_id)
|
||||||
|
|
||||||
|
def _approval_verify(self, approval):
|
||||||
|
return {"valid": approval.get("signature_valid", False), "reason": "ok"}
|
||||||
|
|
||||||
|
def _tolaria_delete(self, vault_path):
|
||||||
|
self.http_calls.append(("DELETE", vault_path))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def _register_valid_approval(self, job):
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
return approval
|
||||||
|
|
||||||
|
def test_t12_delete_executor_cannot_claim_save_job(self):
|
||||||
|
job = _make_save_job()
|
||||||
|
self.store.create_job(job)
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["state"], ST_REJECTED)
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t14_delete_executor_has_no_save_credential(self):
|
||||||
|
self.assertNotIn("save_token", dir(self.store))
|
||||||
|
self.assertNotIn("save_token", dir(DeleteExecutorCore))
|
||||||
|
|
||||||
|
def test_t21_delete_job_without_approval_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["state"], ST_FAILED)
|
||||||
|
self.assertEqual(result["result_code"], "APPROVAL_MISSING")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t22_delete_job_invalid_signature_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["signature_valid"] = False
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "APPROVAL_INVALID")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t23_delete_job_wrong_object_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["object_id"] = "obj-OTHER"
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t24_wrong_path_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["vault_path"] = "/app/vault/notes/OTHER.md"
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "PATH_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t25_wrong_commit_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["expected_commit"] = _sha256("other-commit")
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "COMMIT_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t26_wrong_provenance_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["expected_provenance_hash"] = _sha256("other-content")
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "PROVENANCE_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t27_wrong_mission_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["mission_id"] = _uuid()
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "MISSION_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t28_expired_approval_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["expired"] = True
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "APPROVAL_EXPIRED")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t29_consumed_approval_denied(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["consumed"] = True
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "APPROVAL_CONSUMED")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
|
||||||
|
def test_t30_replay_denied(self):
|
||||||
|
# Nach bestätigtem DELETE: gleicher Job niemals erneut mutieren
|
||||||
|
job = _make_delete_job()
|
||||||
|
self._register_valid_approval(job)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["state"], ST_SUCCEEDED)
|
||||||
|
self.assertEqual(len(self.http_calls), 1)
|
||||||
|
# Replay: Job ist SUCCEEDED, nicht claimbar -> kein 2. HTTP
|
||||||
|
exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(len(self.http_calls), 1)
|
||||||
|
|
||||||
|
def test_t31_valid_delete_reaches_mutation_boundary(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
self._register_valid_approval(job)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["state"], ST_SUCCEEDED)
|
||||||
|
self.assertEqual(self.http_calls, [("DELETE", job["vault_path"])])
|
||||||
|
|
||||||
|
def test_t32_delete_credential_missing_no_http(self):
|
||||||
|
def no_cred_delete(vault_path):
|
||||||
|
self.http_calls.append(("DELETE", vault_path))
|
||||||
|
return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False}
|
||||||
|
job = _make_delete_job()
|
||||||
|
self._register_valid_approval(job)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, no_cred_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["state"], ST_FAILED)
|
||||||
|
self.assertEqual(result["result_code"], "CREDENTIAL_MISSING")
|
||||||
|
|
||||||
|
def test_t37_confirmed_delete_retry_no_second_delete(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
self._register_valid_approval(job)
|
||||||
|
self.store.create_job(job)
|
||||||
|
self.store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
self.store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
# Retry-Versuch -> kein zweiter Delete
|
||||||
|
exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(len(self.http_calls), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class StateMachineTests(unittest.TestCase):
|
||||||
|
"""State-Machine-Transitionen (FAIL CLOSED)."""
|
||||||
|
|
||||||
|
def test_valid_transitions(self):
|
||||||
|
self.assertEqual(transition(ST_CREATED, ST_READY), ST_READY)
|
||||||
|
self.assertEqual(transition(ST_READY, ST_CLAIMED), ST_CLAIMED)
|
||||||
|
self.assertEqual(transition(ST_CLAIMED, ST_EXECUTING), ST_EXECUTING)
|
||||||
|
self.assertEqual(transition(ST_EXECUTING, ST_SUCCEEDED), ST_SUCCEEDED)
|
||||||
|
self.assertEqual(transition(ST_EXECUTING, ST_OUTCOME_UNKNOWN), ST_OUTCOME_UNKNOWN)
|
||||||
|
|
||||||
|
def test_invalid_transition_fail_closed(self):
|
||||||
|
# SUCCEEDED -> READY ist ungültig
|
||||||
|
with self.assertRaises(InvalidTransitionError):
|
||||||
|
transition(ST_SUCCEEDED, ST_READY)
|
||||||
|
# REJECTED -> EXECUTING ist ungültig
|
||||||
|
with self.assertRaises(InvalidTransitionError):
|
||||||
|
transition(ST_REJECTED, ST_EXECUTING)
|
||||||
|
# OUTCOME_UNKNOWN -> READY ist ungültig (kein blinder Retry)
|
||||||
|
with self.assertRaises(InvalidTransitionError):
|
||||||
|
transition(ST_OUTCOME_UNKNOWN, ST_READY)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
593
tolaria/c5-sync-service/test_job_channel_adversarial.py
Normal file
593
tolaria/c5-sync-service/test_job_channel_adversarial.py
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
"""
|
||||||
|
AUTH.3E — test_job_channel_adversarial.py
|
||||||
|
==========================================
|
||||||
|
Adversarial Tests (Mission §22) + Test Sensitivity (Mission §23).
|
||||||
|
|
||||||
|
Adversarial:
|
||||||
|
* JSON duplicate keys
|
||||||
|
* Unicode path ambiguity
|
||||||
|
* encoded traversal
|
||||||
|
* job_type casing tricks
|
||||||
|
* enum confusion
|
||||||
|
* integer/string confusion
|
||||||
|
* oversized payload
|
||||||
|
* stale mission
|
||||||
|
* stale provenance
|
||||||
|
* race claim
|
||||||
|
* replay after restart
|
||||||
|
* DB row manipulation
|
||||||
|
* cross-executor DB injection
|
||||||
|
* approval substitution
|
||||||
|
* object substitution after claim
|
||||||
|
* path substitution after claim
|
||||||
|
* state mutation after approval
|
||||||
|
* crash before HTTP
|
||||||
|
* crash after uncertain HTTP
|
||||||
|
|
||||||
|
Sensitivity (Mutationen A–L): Jede Mutation muss relevante Tests ROT machen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from job_schema import (
|
||||||
|
JOB_TYPE_DELETE,
|
||||||
|
JOB_TYPE_SAVE,
|
||||||
|
JobRejectedError,
|
||||||
|
make_delete_job,
|
||||||
|
make_save_job,
|
||||||
|
validate_job,
|
||||||
|
)
|
||||||
|
from job_state_machine import (
|
||||||
|
ST_CLAIMED,
|
||||||
|
ST_EXECUTING,
|
||||||
|
ST_FAILED,
|
||||||
|
ST_OUTCOME_UNKNOWN,
|
||||||
|
ST_READY,
|
||||||
|
ST_REJECTED,
|
||||||
|
ST_SUCCEEDED,
|
||||||
|
InvalidTransitionError,
|
||||||
|
transition,
|
||||||
|
)
|
||||||
|
from job_store import JobStore
|
||||||
|
from save_executor_core import SaveExecutorCore
|
||||||
|
from delete_executor_core import DeleteExecutorCore
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(content: str) -> str:
|
||||||
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return "2026-08-27T00:00:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_save_job(**overrides) -> dict:
|
||||||
|
job = make_save_job(
|
||||||
|
job_id=_uuid(), mission_id=_uuid(), object_id="obj-001",
|
||||||
|
vault_path="/app/vault/notes/obj-001.md", source_commit=_sha256("commit"),
|
||||||
|
provenance_hash=_sha256("content"), expected_state="ready",
|
||||||
|
created_at=_now_iso(), idempotency_key=_uuid(),
|
||||||
|
)
|
||||||
|
job.update(overrides)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _make_delete_job(**overrides) -> dict:
|
||||||
|
job = make_delete_job(
|
||||||
|
job_id=_uuid(), mission_id=_uuid(), delete_request_id=_uuid(),
|
||||||
|
object_id="obj-001", vault_path="/app/vault/notes/obj-001.md",
|
||||||
|
expected_commit=_sha256("commit"), expected_provenance_hash=_sha256("content"),
|
||||||
|
approval_id=_uuid(), created_at=_now_iso(), idempotency_key=_uuid(),
|
||||||
|
)
|
||||||
|
job.update(overrides)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_approval(job: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"approval_id": job["approval_id"], "mission_id": job["mission_id"],
|
||||||
|
"object_id": job["object_id"], "vault_path": job["vault_path"],
|
||||||
|
"expected_commit": job["expected_commit"],
|
||||||
|
"expected_provenance_hash": job["expected_provenance_hash"],
|
||||||
|
"nonce": _uuid(), "expired": False, "consumed": False,
|
||||||
|
"signature_valid": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AdversarialSchemaTests(unittest.TestCase):
|
||||||
|
"""Adversarial: Schema-Angriffe."""
|
||||||
|
|
||||||
|
def test_json_duplicate_keys(self):
|
||||||
|
# JSON mit doppelten Keys -> parse_payload (AUTH.3D) lehnt ab.
|
||||||
|
# Hier: defensiv — validate_job auf einem dict mit doppeltem Key.
|
||||||
|
job = _make_save_job()
|
||||||
|
# Simuliere Duplicate-Key durch dict mit zwei gleichen Keys (letzter gewinnt)
|
||||||
|
# -> kein Fehler, aber wir prüfen, dass kein privilegiertes Feld eingeschleust wird
|
||||||
|
job["command"] = "evil"
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_unicode_path_ambiguity(self):
|
||||||
|
# Unicode-Homoglyph / nicht-NFC-normalisierter Pfad
|
||||||
|
job = _make_save_job(vault_path="/app/vault/notes/obj\u0301.md") # e + combining accent
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_encoded_traversal(self):
|
||||||
|
# URL-encoded traversal
|
||||||
|
job = _make_save_job(vault_path="/app/vault/%2e%2e/etc/passwd")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_job_type_casing_tricks(self):
|
||||||
|
job = _make_save_job(job_type="c5_save_object")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_enum_confusion(self):
|
||||||
|
# job_type als Nicht-String (z.B. int)
|
||||||
|
job = _make_save_job(job_type=123)
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_integer_string_confusion(self):
|
||||||
|
# job_version als String statt int
|
||||||
|
job = _make_save_job(job_version="1")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_oversized_payload(self):
|
||||||
|
# Riesiger object_id
|
||||||
|
job = _make_save_job(object_id="x" * 100000)
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_stale_mission(self):
|
||||||
|
# mission_id ungültig (kein UUID)
|
||||||
|
job = _make_save_job(mission_id="stale-mission")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_stale_provenance(self):
|
||||||
|
# provenance_hash ungültig (kein sha256)
|
||||||
|
job = _make_save_job(provenance_hash="not-a-hash")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
class AdversarialRuntimeTests(unittest.TestCase):
|
||||||
|
"""Adversarial: Runtime-Angriffe."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
|
||||||
|
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
|
||||||
|
self.http_calls = []
|
||||||
|
self.approvals = {}
|
||||||
|
self.content = "hello world"
|
||||||
|
self.provenance = _sha256(self.content)
|
||||||
|
|
||||||
|
def _source_loader(self, commit, object_id):
|
||||||
|
return self.content
|
||||||
|
|
||||||
|
def _tolaria_save(self, vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def _approval_loader(self, approval_id):
|
||||||
|
return self.approvals.get(approval_id)
|
||||||
|
|
||||||
|
def _approval_verify(self, approval):
|
||||||
|
return {"valid": approval.get("signature_valid", False), "reason": "ok"}
|
||||||
|
|
||||||
|
def _tolaria_delete(self, vault_path):
|
||||||
|
self.http_calls.append(("DELETE", vault_path))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def test_race_claim(self):
|
||||||
|
# Zwei Worker claimen denselben Job -> nur einer gewinnt
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
from job_claim import JobAlreadyClaimedError
|
||||||
|
store.claim_job(job["job_id"], "worker-1")
|
||||||
|
with self.assertRaises(JobAlreadyClaimedError):
|
||||||
|
store.claim_job(job["job_id"], "worker-2")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_replay_after_restart(self):
|
||||||
|
# Nach Restart (neuer Store auf gleicher DB) kein Doppel-Delete
|
||||||
|
job = _make_delete_job()
|
||||||
|
self.approvals[job["approval_id"]] = _valid_approval(job)
|
||||||
|
store1 = JobStore(self.delete_db, "DELETE")
|
||||||
|
store1.create_job(job)
|
||||||
|
store1.mark_ready(job["job_id"])
|
||||||
|
exec1 = DeleteExecutorCore(
|
||||||
|
store1, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
exec1.process_job(job["job_id"], "delete-worker")
|
||||||
|
store1.close()
|
||||||
|
# Restart
|
||||||
|
store2 = JobStore(self.delete_db, "DELETE")
|
||||||
|
exec2 = DeleteExecutorCore(
|
||||||
|
store2, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
exec2.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(len(self.http_calls), 1) # kein 2. Delete
|
||||||
|
store2.close()
|
||||||
|
|
||||||
|
def test_db_row_manipulation(self):
|
||||||
|
# Manipulierter Payload (job_type geändert) -> Executor lehnt ab
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
# Manipulation: Payload job_type auf DELETE ändern (Angriffsfläche)
|
||||||
|
row = store._conn.execute(
|
||||||
|
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
|
||||||
|
).fetchone()
|
||||||
|
payload = json.loads(row[0])
|
||||||
|
payload["job_type"] = JOB_TYPE_DELETE
|
||||||
|
store._conn.execute(
|
||||||
|
"UPDATE jobs SET payload = ? WHERE job_id = ?",
|
||||||
|
(json.dumps(payload), job["job_id"]),
|
||||||
|
)
|
||||||
|
store._conn.commit()
|
||||||
|
exec_core = SaveExecutorCore(store, self._source_loader, self._tolaria_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_REJECTED)
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_cross_executor_db_injection(self):
|
||||||
|
# DELETE-Job in SAVE-DB injiziert -> SAVE-Executor lehnt ab
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_delete_job()
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(store, self._source_loader, self._tolaria_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_REJECTED)
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_approval_substitution(self):
|
||||||
|
# Approval für anderes Objekt substituiert -> denied
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["object_id"] = "obj-OTHER"
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_object_substitution_after_claim(self):
|
||||||
|
# object_id im Payload manipuliert -> Executor validiert gegen Approval
|
||||||
|
# (TOCTOU-Defense: Job wird nach dem Claim neu gelesen)
|
||||||
|
job = _make_delete_job()
|
||||||
|
self.approvals[job["approval_id"]] = _valid_approval(job)
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
# Payload manipulieren (object_id ändern)
|
||||||
|
row = store._conn.execute(
|
||||||
|
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
|
||||||
|
).fetchone()
|
||||||
|
payload = json.loads(row[0])
|
||||||
|
payload["object_id"] = "obj-OTHER"
|
||||||
|
store._conn.execute(
|
||||||
|
"UPDATE jobs SET payload = ? WHERE job_id = ?",
|
||||||
|
(json.dumps(payload), job["job_id"]),
|
||||||
|
)
|
||||||
|
store._conn.commit()
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_path_substitution_after_claim(self):
|
||||||
|
job = _make_delete_job()
|
||||||
|
self.approvals[job["approval_id"]] = _valid_approval(job)
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
row = store._conn.execute(
|
||||||
|
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
|
||||||
|
).fetchone()
|
||||||
|
payload = json.loads(row[0])
|
||||||
|
payload["vault_path"] = "/app/vault/notes/OTHER.md"
|
||||||
|
store._conn.execute(
|
||||||
|
"UPDATE jobs SET payload = ? WHERE job_id = ?",
|
||||||
|
(json.dumps(payload), job["job_id"]),
|
||||||
|
)
|
||||||
|
store._conn.commit()
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "PATH_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_state_mutation_after_approval(self):
|
||||||
|
# expected_commit im Payload mutiert -> Executor validiert gegen Approval
|
||||||
|
job = _make_delete_job()
|
||||||
|
self.approvals[job["approval_id"]] = _valid_approval(job)
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
row = store._conn.execute(
|
||||||
|
"SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],)
|
||||||
|
).fetchone()
|
||||||
|
payload = json.loads(row[0])
|
||||||
|
payload["expected_commit"] = _sha256("other-commit")
|
||||||
|
store._conn.execute(
|
||||||
|
"UPDATE jobs SET payload = ? WHERE job_id = ?",
|
||||||
|
(json.dumps(payload), job["job_id"]),
|
||||||
|
)
|
||||||
|
store._conn.commit()
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "COMMIT_MISMATCH")
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_crash_before_http(self):
|
||||||
|
# Crash vor HTTP -> Job bleibt CLAIMED, Lease läuft ab, SAVE wird recovered
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
store.claim_job(job["job_id"], "worker-1", lease_seconds=0)
|
||||||
|
# Crash simuliert: kein HTTP, kein weiterer Schritt
|
||||||
|
self.assertEqual(self.http_calls, [])
|
||||||
|
# Recovery: SAVE -> READY
|
||||||
|
store.recover_stale_claims("worker-2")
|
||||||
|
self.assertEqual(store.get_job(job["job_id"])["state"], ST_READY)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_crash_after_uncertain_http(self):
|
||||||
|
# Crash nach unklarem HTTP -> OUTCOME_UNKNOWN, kein blinder Retry
|
||||||
|
def uncertain_delete(vault_path):
|
||||||
|
self.http_calls.append(("DELETE", vault_path))
|
||||||
|
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
|
||||||
|
job = _make_delete_job()
|
||||||
|
self.approvals[job["approval_id"]] = _valid_approval(job)
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, uncertain_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["state"], ST_OUTCOME_UNKNOWN)
|
||||||
|
# Kein blinder Retry
|
||||||
|
exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(len(self.http_calls), 1)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SensitivityTests(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Test Sensitivity (Mission §23): Mutationen A–L müssen relevante Tests ROT machen.
|
||||||
|
|
||||||
|
Diese Tests prüfen die INVARIANTEN direkt. Wenn eine Mutation die Invariante
|
||||||
|
entfernt, schlägt der Test fehl (ROT). Die Mutationen selbst werden in
|
||||||
|
sensitivity_proof.py simuliert (statische Analyse).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_job_type_allowlist_removed(self):
|
||||||
|
# Invariante: job_type muss in geschlossener Allowlist sein
|
||||||
|
job = _make_save_job(job_type="C5_EXECUTE")
|
||||||
|
with self.assertRaises(JobRejectedError):
|
||||||
|
validate_job(job)
|
||||||
|
|
||||||
|
def test_b_save_delete_worker_scope_removed(self):
|
||||||
|
# Invariante: SAVE-Executor claimt nur SAVE-Jobs
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_delete_job()
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(
|
||||||
|
store, self._source_loader, self._tolaria_save
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["state"], ST_REJECTED)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_c_atomic_claim_removed(self):
|
||||||
|
# Invariante: Claim ist atomar (nur ein Worker gewinnt)
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
from job_claim import JobAlreadyClaimedError
|
||||||
|
store.claim_job(job["job_id"], "worker-1")
|
||||||
|
with self.assertRaises(JobAlreadyClaimedError):
|
||||||
|
store.claim_job(job["job_id"], "worker-2")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_d_idempotency_removed(self):
|
||||||
|
# Invariante: duplicate job_id idempotent
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job()
|
||||||
|
store.create_job(job)
|
||||||
|
store.create_job(job)
|
||||||
|
self.assertEqual(len(store.list_jobs()), 1)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_e_approval_requirement_removed(self):
|
||||||
|
# Invariante: DELETE ohne Approval denied
|
||||||
|
job = _make_delete_job()
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "APPROVAL_MISSING")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_f_signature_verification_removed(self):
|
||||||
|
# Invariante: invalid Signature denied
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["signature_valid"] = False
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "APPROVAL_INVALID")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_g_mission_binding_removed(self):
|
||||||
|
# Invariante: falsche Mission denied
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["mission_id"] = _uuid()
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "MISSION_MISMATCH")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_h_object_binding_removed(self):
|
||||||
|
# Invariante: falsches Object denied
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["object_id"] = "obj-OTHER"
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "OBJECT_MISMATCH")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_i_commit_binding_removed(self):
|
||||||
|
# Invariante: falscher Commit denied
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["expected_commit"] = _sha256("other-commit")
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "COMMIT_MISMATCH")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_j_provenance_binding_removed(self):
|
||||||
|
# Invariante: falsche Provenance denied
|
||||||
|
job = _make_delete_job()
|
||||||
|
approval = _valid_approval(job)
|
||||||
|
approval["expected_provenance_hash"] = _sha256("other-content")
|
||||||
|
self.approvals[job["approval_id"]] = approval
|
||||||
|
store = JobStore(self.delete_db, "DELETE")
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = DeleteExecutorCore(
|
||||||
|
store, self._approval_loader, self._approval_verify, self._tolaria_delete
|
||||||
|
)
|
||||||
|
result = exec_core.process_job(job["job_id"], "delete-worker")
|
||||||
|
self.assertEqual(result["result_code"], "PROVENANCE_MISMATCH")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_k_outcome_unknown_blind_retry_removed(self):
|
||||||
|
# Invariante: OUTCOME_UNKNOWN -> kein blinder Retry
|
||||||
|
def uncertain_save(vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "error", "code": "TIMEOUT", "uncertain": True}
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(store, self._source_loader, uncertain_save)
|
||||||
|
exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(len(self.http_calls), 1)
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_l_credential_fail_closed_removed(self):
|
||||||
|
# Invariante: Credential fehlt -> kein HTTP
|
||||||
|
def no_cred_save(vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False}
|
||||||
|
store = JobStore(self.save_db, "SAVE")
|
||||||
|
job = _make_save_job(provenance_hash=self.provenance)
|
||||||
|
store.create_job(job)
|
||||||
|
store.mark_ready(job["job_id"])
|
||||||
|
exec_core = SaveExecutorCore(store, self._source_loader, no_cred_save)
|
||||||
|
result = exec_core.process_job(job["job_id"], "save-worker")
|
||||||
|
self.assertEqual(result["result_code"], "CREDENTIAL_MISSING")
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.save_db = os.path.join(self.tmpdir, "c5a_save.db")
|
||||||
|
self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db")
|
||||||
|
self.http_calls = []
|
||||||
|
self.approvals = {}
|
||||||
|
self.content = "hello world"
|
||||||
|
self.provenance = _sha256(self.content)
|
||||||
|
|
||||||
|
def _source_loader(self, commit, object_id):
|
||||||
|
return self.content
|
||||||
|
|
||||||
|
def _tolaria_save(self, vault_path, content):
|
||||||
|
self.http_calls.append(("SAVE", vault_path))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
def _approval_loader(self, approval_id):
|
||||||
|
return self.approvals.get(approval_id)
|
||||||
|
|
||||||
|
def _approval_verify(self, approval):
|
||||||
|
return {"valid": approval.get("signature_valid", False), "reason": "ok"}
|
||||||
|
|
||||||
|
def _tolaria_delete(self, vault_path):
|
||||||
|
self.http_calls.append(("DELETE", vault_path))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
711
tolaria/tolaria-write-auth/AUTH3B_DESIGN.md
Normal file
711
tolaria/tolaria-write-auth/AUTH3B_DESIGN.md
Normal file
|
|
@ -0,0 +1,711 @@
|
||||||
|
# AUTH.3B — SECRET / ENV / RUNTIME INJECTION DESIGN + DEPLOYMENT PRE-FLIGHT
|
||||||
|
|
||||||
|
**Status:** DESIGN + VERIFIED DEPLOYMENT PLAN (STRICT READ-ONLY, KEIN produktives Deployment)
|
||||||
|
**Datum:** 2026-08-27
|
||||||
|
**Mission Type:** STRICT DESIGN + READ-ONLY DISCOVERY + ISOLATED TESTS
|
||||||
|
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) = COMPLETE
|
||||||
|
|
||||||
|
> ⚠️ Dies ist ein **Design-/Plan-Dokument**. Es wird NICHTS produktiv deployt,
|
||||||
|
> kein Token erzeugt, keine ENV/Compose/Coolify geändert, kein Container
|
||||||
|
> restart/recreate, keine produktive Tolaria-Auth aktiviert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. RUNTIME DISCOVERY (Phase 1) — IST-Zustand
|
||||||
|
|
||||||
|
### A) Tolaria Server
|
||||||
|
| Attribut | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Container | `tolaria` |
|
||||||
|
| Runtime | Node.js 20 / Vite (dev server, `vite.config.ts` middleware) |
|
||||||
|
| Host Source | `/opt/tolaria/app/tolaria` |
|
||||||
|
| Container Source | `/app/tolaria` |
|
||||||
|
| Vault | `/opt/data/tolaria-vault` → `/app/vault` (Bind-Mount) |
|
||||||
|
| Compose | `/opt/tolaria/docker-compose.yml` |
|
||||||
|
| Port | 5173 (produktiv); Repo-`vite.config.ts` deklariert 5202 (dev) |
|
||||||
|
| Produktiver Source-Basisstand | `b5a5e4bd` (VOR AUTH.2) |
|
||||||
|
| AUTH.2 deployed? | **NEIN** — Produktion läuft noch OHNE neue Auth (verifiziert: save ohne Token → 200) |
|
||||||
|
| ENV erwartet (Code) | `TOLARIA_SAVE_TOKEN`, `TOLARIA_DELETE_TOKEN`, `TOLARIA_VAULT_ROOT` (Default `/app/vault`) |
|
||||||
|
| Fail-closed | JA — `authorizeVaultWrite` Z.120-123: leeres `expected` → 401 "Write scope not configured" |
|
||||||
|
|
||||||
|
### B) C5C Writer (SAVE)
|
||||||
|
| Attribut | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Klasse | `C5CPropagator` (`rq_c5c.py` Z.497) |
|
||||||
|
| Write-Methode | `TolariaClient.write()` (Z.315) → POST `/api/vault/save`, Bearer mit `save_token` |
|
||||||
|
| Credential-Quelle | NUR explizit injizierter Client (`client=...`); Default `TolariaClient()` credential-frei → fail-closed |
|
||||||
|
| ENV | `TOLARIA_SAVE_TOKEN` (Konstante Z.100); `rq_c5c.py` liest ENV NICHT selbst |
|
||||||
|
| CLI-Entrypoint | **KEINER** — kein CLI-Command ruft C5CPropagator mit save_token auf |
|
||||||
|
| Produktiver Aufrufer | `rq_c5f_resume.py` (untracked) — konstruiert `TolariaClient()` OHNE save_token → mit AUTH.3A fail-closed |
|
||||||
|
| Runtime | RQ-Container (`06a4a73573d0`), C5-DB `/opt/data/c5c_live/c5a.db` |
|
||||||
|
|
||||||
|
### C) C5 DeleteExecutor (DELETE)
|
||||||
|
| Attribut | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Klasse | `DeleteExecutor` (`rq_c5_delete.py`) |
|
||||||
|
| Write-Methode | `TolariaClient.delete()` → POST `/api/vault/delete`, Bearer mit `delete_token` |
|
||||||
|
| Credential-Quelle | `_delete_executor` (`rq_c5_cli.py` Z.344): `delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN)` |
|
||||||
|
| ENV | `TOLARIA_DELETE_TOKEN` (Konstante Z.101) |
|
||||||
|
| CLI-Entrypoint | `c5-delete-execute`, `c5-delete-replay` (Human-Gated) |
|
||||||
|
| Runtime | RQ-Container (`06a4a73573d0`), C5-DB `/opt/data/c5c_live/c5a.db` |
|
||||||
|
|
||||||
|
### D) Red Queen / Hermes
|
||||||
|
| Attribut | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Container | `06a4a73573d0` (RQ/Hermes) |
|
||||||
|
| C5-Caller | laufen IM RQ-Container (C5-DB `/opt/data/c5c_live/c5a.db`) |
|
||||||
|
| Docker-Daemon | NICHT verfügbar aus RQ-Container (kein `/var/run/docker.sock`) |
|
||||||
|
| VPS-Host-SSH | NICHT verfügbar (Key `red_queen_forgejo` nur für Forgejo-Git-Push) |
|
||||||
|
| Tolaria-API | read-only erreichbar (`187.124.31.123:5173`) |
|
||||||
|
|
||||||
|
### E) Rain / OpenClaw
|
||||||
|
| Attribut | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Ports | 55163 (Alice), 54524 (Matt Addison) |
|
||||||
|
| Zugriff auf C5-DB | NICHT verifiziert (separate Container) |
|
||||||
|
| Tolaria-Write-Credential | KEIN (Architektur-Invariante) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. KRITISCHER BEFUND (Phase 7-Vorab)
|
||||||
|
|
||||||
|
**SAVE-Caller (C5CPropagator) und DELETE-Caller (DeleteExecutor) laufen im SELBEN
|
||||||
|
Prozess/Container (RQ-Container `06a4a73573d0`).** Es existiert KEINE getrennte
|
||||||
|
Runtime-Boundary zwischen SAVE und DELETE. Beide Credentials würden, wenn beide
|
||||||
|
injiziert würden, im selben Container landen.
|
||||||
|
|
||||||
|
**Konsequenz:** Die Credential-Separation (SAVE≠DELETE) ist heute NUR logisch
|
||||||
|
(verschiedene ENV-Namen, verschiedene Klassen), NICHT technisch als getrennte
|
||||||
|
Security Boundaries. Das muss in Phase 7 explizit dokumentiert und in Phase 3/4
|
||||||
|
bewertet werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. SECURITY BOUNDARY ANALYSIS (Phase 2) — IST-Zustand
|
||||||
|
|
||||||
|
### Runtime-Fakten (verifiziert)
|
||||||
|
- **RQ/Hermes-Container** = `06a4a73573d0`, User `hermes` (uid 10010, gid 10000).
|
||||||
|
- **Kein Docker-Socket** im RQ-Container (`/var/run/docker.sock` fehlt) → RQ kann
|
||||||
|
keine anderen Container inspizieren.
|
||||||
|
- **Kein VPS-Host-SSH** (Key `red_queen_forgejo` nur für Forgejo-Git-Push) → RQ kann
|
||||||
|
Host-Dateien (`/opt/tolaria`, Compose) NICHT lesen.
|
||||||
|
- **Kein `/opt/tolaria`** im RQ-Container-Dateisystem.
|
||||||
|
- **/proc** im RQ-Container zeigt nur eigene Prozesse (24 PIDs) → kein Cross-Container
|
||||||
|
/proc-Zugriff.
|
||||||
|
- **Netzwerk:** RQ erreicht Tolaria intern (`10.0.4.1:5173` → `{"ok":true}`) und
|
||||||
|
Forgejo (`10.0.4.1:3000`). Tolaria läuft in SEPARATEM Container auf VPS-Host.
|
||||||
|
- **C5-Caller:** KEIN Daemon, KEIN separater Container. `C5CPropagator` (SAVE) und
|
||||||
|
`DeleteExecutor` (DELETE) sind Library/CLI, die manuell im RQ-Container aufgerufen
|
||||||
|
werden (Hermes-Gateway-Prozess). C5-DB `/opt/data/c5c_live/c5a.db` liegt im RQ-Container.
|
||||||
|
- **ENV im RQ-Container:** `TOLARIA_SAVE_TOKEN` ABSENT, `TOLARIA_DELETE_TOKEN` ABSENT
|
||||||
|
(korrekt — RQ credential-free). Vorhandene Secrets: `FORGEJO_WRITE_TOKEN`,
|
||||||
|
`NOTION_API_KEY`, `TELEGRAM_BOT_TOKEN`, `HERMES_SESSION_KEY` (Namen, keine Werte).
|
||||||
|
|
||||||
|
### CAN_*_READ_SECRET — SAVE und DELETE (identisch, da gleiche Boundary)
|
||||||
|
|
||||||
|
| Akteur | SAVE | DELETE | Weg |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **RQ/Hermes** (Container `06a4a73573d0`) | **NEIN** (tatsächlich) | **NEIN** (tatsächlich) | Kein Docker-Socket, kein Host-SSH, kein /proc-Cross, kein /opt/tolaria. ENV ABSENT. |
|
||||||
|
| **RQ/Hermes** (theoretisch via root) | JA (wenn Host-root) | JA (wenn Host-root) | Host-root kann Container-ENV via `docker inspect`/`docker exec` lesen. |
|
||||||
|
| **Rain/OpenClaw** (Alice 55163, Matt 54524) | **NEIN** (tatsächlich) | **NEIN** (tatsächlich) | Separate Container, kein Zugriff auf RQ-ENV oder Tolaria-Container. |
|
||||||
|
| **Anderer Container** (Tolaria selbst) | JA (eigener ENV) | JA (eigener ENV) | Tolaria-Container hält seine eigenen erwarteten Tokens. |
|
||||||
|
| **Docker-Admin** (VPS-Host) | JA | JA | `docker inspect`/`docker exec` auf Tolaria-Container. |
|
||||||
|
| **Host-Root** (VPS) | JA | JA | Liest Compose, .env, Container-ENV, Mounts. |
|
||||||
|
| **C5C Writer** (SAVE-Prozess) | JA (SAVE) | NEIN (kein DELETE) | Erhält nur SAVE-Token via injiziertem Client. |
|
||||||
|
| **DeleteExecutor** (DELETE-Prozess) | NEIN (kein SAVE) | JA (DELETE) | Erhält nur DELETE-Token via ENV. |
|
||||||
|
|
||||||
|
### Security-Boundary-Befund
|
||||||
|
- **Tolaria-Server** = eigene Boundary (separater Container, VPS-Host).
|
||||||
|
- **RQ/Hermes** = eigene Boundary (Container `06a4a73573d0`), aber **C5-Caller
|
||||||
|
(SAVE+DELETE) laufen INNERHALB dieser Boundary**.
|
||||||
|
- **CRITICAL:** SAVE- und DELETE-Credential würden, wenn beide injiziert würden,
|
||||||
|
im **selben Container** (`06a4a73573d0`) landen. Es gibt KEINE getrennte
|
||||||
|
Runtime-Boundary zwischen SAVE-Caller und DELETE-Caller.
|
||||||
|
- **Konsequenz für Phase 3:** Die Zielarchitektur "SAVE≠DELETE als getrennte
|
||||||
|
Boundaries" ist mit dem realen Deployment **NICHT vollständig erreichbar**, solange
|
||||||
|
beide Caller im selben RQ-Container laufen. Die Separation ist NUR logisch
|
||||||
|
(verschiedene ENV-Namen, verschiedene Klassen, verschiedene CLI-Commands), NICHT
|
||||||
|
technisch als getrennte Container/Prozesse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3b. CREDENTIAL OWNERSHIP MODEL (Phase 3) — Zielarchitektur vs. Realität
|
||||||
|
|
||||||
|
### Zielarchitektur (verbindlich aus Freigabe)
|
||||||
|
- SAVE credential → Tolaria Server + C5C Writer
|
||||||
|
- DELETE credential → Tolaria Server + C5 DeleteExecutor
|
||||||
|
- RQ: KEIN SAVE, KEIN DELETE
|
||||||
|
- Hermes generisch: KEIN SAVE, KEIN DELETE
|
||||||
|
- Rain: KEIN SAVE, KEIN DELETE
|
||||||
|
- Kein Master-Token
|
||||||
|
- SAVE darf DELETE nicht autorisieren; DELETE darf SAVE nicht autorisieren
|
||||||
|
|
||||||
|
### Erreichbarkeit mit realem Deployment
|
||||||
|
|
||||||
|
**1) Server-seitige Auth (AUTH.2) — ERREICHBAR ✓**
|
||||||
|
- Tolaria-Server hält SAVE+DELETE-Tokens in seiner eigenen ENV (separater Container).
|
||||||
|
- RQ/Hermes/Rain haben KEINEN Zugriff auf den Tolaria-Container (kein Docker-Socket,
|
||||||
|
kein Host-SSH, kein /proc-Cross).
|
||||||
|
- Server-seitige Scope-Trennung (SAVE≠DELETE) ist im Code verifiziert (AUTH.2).
|
||||||
|
|
||||||
|
**2) Caller-seitige Credential-Separation — NICHT ERREICHBAR (HARD STOP-Kandidat) ✗**
|
||||||
|
- **Ursache:** C5C Writer (SAVE) und DeleteExecutor (DELETE) laufen im **selben
|
||||||
|
Container** wie RQ/Hermes (`06a4a73573d0`). Es gibt KEINE getrennte Runtime-Boundary.
|
||||||
|
- Wenn SAVE- und DELETE-Tokens in die ENV dieses Containers injiziert würden, hätte
|
||||||
|
RQ/Hermes (das im selben Container läuft, gleicher User `hermes`) **technischen
|
||||||
|
Zugriff auf BEIDE Tokens**.
|
||||||
|
- Das verletzt die Zielarchitektur "RQ: KEIN SAVE KEIN DELETE" und "Hermes: KEIN
|
||||||
|
SAVE KEIN DELETE" — unabhängig davon, ob RQ den Zugriff tatsächlich nutzt.
|
||||||
|
|
||||||
|
### HARD STOP — exakte Ursache
|
||||||
|
**Die Zielarchitektur "RQ/Hermes credential-free" ist mit dem realen Deployment
|
||||||
|
NICHT erreichbar, solange die C5-Caller (SAVE+DELETE) im selben Container wie
|
||||||
|
RQ/Hermes laufen und ihre Tokens in dessen ENV liegen.**
|
||||||
|
|
||||||
|
Notwendige Voraussetzung für AUTH.4 (Architektur-Änderung, NICHT in AUTH.3B):
|
||||||
|
- **Option A (empfohlen):** C5C Writer und DeleteExecutor in einen **separaten
|
||||||
|
Container/Prozess** mit eigener Boundary verschieben, der NICHT der RQ-Container
|
||||||
|
ist. RQ/Hermes ruft diesen Container nur über eine schmale Schnittstelle auf.
|
||||||
|
- **Option B:** Tokens über einen **separaten Secret-Store** injizieren, auf den nur
|
||||||
|
der C5-Caller-Prozess (nicht RQ/Hermes) Zugriff hat. Im selben Container mit
|
||||||
|
gleichem User ist das praktisch nicht durchsetzbar (RQ kann dieselbe Datei lesen).
|
||||||
|
|
||||||
|
**Ohne diese Änderung bleibt die Separation NUR logisch** (verschiedene ENV-Namen,
|
||||||
|
verschiedene Klassen, verschiedene CLI-Commands), NICHT technisch als getrennte
|
||||||
|
Security Boundaries. Das wird NICHT stillschweigend abgeschwächt — es wird als
|
||||||
|
HARD STOP für die volle Zielarchitektur dokumentiert.
|
||||||
|
|
||||||
|
### Was TROTZDEM sicher erreichbar ist (ohne Architektur-Änderung)
|
||||||
|
- **Server-seitige Auth (AUTH.2) aktivieren:** Tolaria-Server fail-closed, SAVE≠DELETE
|
||||||
|
Scope-Trennung. RQ bleibt credential-free (kein Zugriff auf Tolaria-Container).
|
||||||
|
- **SAVE-Aktivierung:** C5C Writer erhält SAVE-Token. ABER: da im RQ-Container,
|
||||||
|
hätte RQ technisch Zugriff → verletzt "RQ credential-free" für SAVE.
|
||||||
|
- **DELETE-Aktivierung:** DeleteExecutor erhält DELETE-Token. ABER: da im RQ-Container,
|
||||||
|
hätte RQ technisch Zugriff → verletzt "RQ credential-free" für DELETE.
|
||||||
|
|
||||||
|
### Fazit Phase 3
|
||||||
|
- **SERVER_AUTH_DEPLOYMENT:** ERREICHBAR (RQ bleibt credential-free ggü. Tolaria).
|
||||||
|
- **SAVE_ACTIVATION / DELETE_CREDENTIAL_ACTIVATION:** BLOCKED bis C5-Caller in
|
||||||
|
getrennte Boundary verschoben ODER Secret-Injection RQ-undurchdringbar ist.
|
||||||
|
- **Kein Master-Token:** ERREICHBAR (AUTH.2/AUTH.3A verifiziert, kein Master-Key).
|
||||||
|
- **SAVE≠DELETE Scope-Trennung:** Server-seitig ERREICHBAR; Caller-seitig nur logisch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4b. SECRET STORAGE OPTIONS (Phase 4) — Bewertung
|
||||||
|
|
||||||
|
### Kontext: Was existiert im realen Stack?
|
||||||
|
- **Coolify-Suite** ist auf dem VPS installiert (infrastructure-handbook Z.52).
|
||||||
|
- **Exakte Tolaria-Provisionierung (Compose vs Coolify) ist aus RQ-Sicht UNKNOWN**
|
||||||
|
(kein Docker-Socket, kein Host-SSH). CURRENT_STATE Z.27: "UNKNOWN: Container-Name/
|
||||||
|
Image-Konfiguration, Env-Variablen, Mounts, Volumes — nur über Host-/Docker-Zugriff
|
||||||
|
ermittelbar, der nicht verfügbar ist."
|
||||||
|
- **Kein separater Secret-Store** (kein Vault/Consul/SOPS/age) im Stack nachweisbar.
|
||||||
|
- **Kein Docker-Secrets-Mechanismus** im Stack nachweisbar (kein Swarm).
|
||||||
|
- Tolaria-Server liest ENV via `process.env.TOLARIA_SAVE_TOKEN` (vite.config.ts).
|
||||||
|
|
||||||
|
### Optionen-Bewertung (SAVE und DELETE, identisch)
|
||||||
|
|
||||||
|
| Kriterium | A) Plain Compose ENV | B) .env-Datei | C) Docker Secrets | D) read-only Secret-Datei | E) Coolify Secret Mgmt | F) separater Secret-Store |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| SECURITY | Schwach (ENV in inspect) | Mittel (Datei, chmod) | Stark (nur im Container) | Mittel (Datei, chmod) | Stark (Coolify-UI) | Stark |
|
||||||
|
| VISIBILITY | `docker inspect` sichtbar | Datei, nicht inspect | Nicht in inspect | Datei, nicht inspect | Nicht in inspect | Nicht in inspect |
|
||||||
|
| DOCKER_INSPECT_EXPOSURE | **JA** | NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| HOST_EXPOSURE | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) |
|
||||||
|
| RQ_EXPOSURE | JA (wenn im RQ-Container) | JA (wenn im RQ-Container) | NEIN (nur Ziel-Container) | JA (wenn im RQ-Container) | NEIN | NEIN |
|
||||||
|
| ROTATION | Restart nötig | Restart nötig | Restart nötig | Restart nötig | Restart nötig | Restart nötig |
|
||||||
|
| REVOCATION | ENV entfernen + Restart | Datei löschen + Restart | Secret entfernen + Restart | Datei löschen + Restart | ENV entfernen + Restart | Store-Update + Restart |
|
||||||
|
| RESTART_BEHAVIOR | ENV bleibt | Datei bleibt | Secret bleibt | Datei bleibt | ENV bleibt | Store bleibt |
|
||||||
|
| BACKUP_BEHAVIOR | ENV nicht in Backup | Datei evtl. in Backup | Secret nicht in Backup | Datei evtl. in Backup | ENV nicht in Backup | Store in Backup |
|
||||||
|
| OPERATIONAL_COMPLEXITY | Niedrig | Niedrig | Mittel (Swarm nötig) | Niedrig | Mittel | Hoch |
|
||||||
|
| COMPATIBILITY | Hoch (Vite liest ENV) | Hoch | Niedrig (kein Swarm) | Hoch (Datei lesen) | Mittel (Coolify vorhanden) | Niedrig (nicht vorhanden) |
|
||||||
|
| BLAST_RADIUS | Hoch (inspect) | Mittel | Niedrig | Mittel | Niedrig | Niedrig |
|
||||||
|
|
||||||
|
### Empfehlung für UNSEREN Stack
|
||||||
|
**Minimal sichere Variante: B) .env-Datei (oder D) read-only Secret-Datei) mit
|
||||||
|
strikten Permissions, injiziert in die Tolaria-Container-ENV.**
|
||||||
|
|
||||||
|
Begründung:
|
||||||
|
- **A (Plain Compose ENV) ist abzulehnen:** `docker inspect` würde die Tokenwerte
|
||||||
|
offenlegen (DOCKER_INSPECT_EXPOSURE=JA). Das verletzt die Anforderung "kein Secret
|
||||||
|
in docker inspect".
|
||||||
|
- **C (Docker Secrets) ist nicht kompatibel:** erfordert Docker Swarm, das im Stack
|
||||||
|
nicht nachweisbar ist.
|
||||||
|
- **F (separater Secret-Store) ist nicht vorhanden:** keine neue Infrastruktur
|
||||||
|
erfinden (Freigabe-Anforderung).
|
||||||
|
- **E (Coolify Secret Mgmt):** Coolify ist vorhanden, aber die exakte Tolaria-
|
||||||
|
Provisionierung ist UNKNOWN. Falls Tolaria über Coolify deployed wird, ist Coolify
|
||||||
|
Secret Management die natürliche Wahl (nicht in inspect, Rotation via UI).
|
||||||
|
- **B/D sind die robuste Default-Empfehlung:** `.env`-Datei mit `chmod 600`, vom
|
||||||
|
Compose/Coolify in die Container-ENV injiziert. Tokenwerte erscheinen NICHT in
|
||||||
|
`docker inspect` (nur der ENV-Name, nicht der Wert, wenn via `env_file` injiziert).
|
||||||
|
|
||||||
|
**WICHTIG (Phase 3-Kopplung):** Unabhängig von der Storage-Option bleibt der
|
||||||
|
HARD STOP bestehen: Solange die C5-Caller im RQ-Container laufen, würde jede
|
||||||
|
Injektion in dessen ENV RQ technischen Zugriff geben. Die Storage-Empfehlung gilt
|
||||||
|
daher primär für den **Tolaria-Server-Container** (eigene Boundary, RQ-fern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5b. TOKEN GENERATION CONTRACT (Phase 5) — NUR DESIGN, KEINE echten Tokens
|
||||||
|
|
||||||
|
> ⚠️ Dies ist ein **Design-Vertrag**. Es wird KEIN echtes Token erzeugt, kein Wert
|
||||||
|
> angezeigt, nichts in ENV/Compose/Logs/Telegram/Repo geschrieben.
|
||||||
|
|
||||||
|
### Kryptographische Zufallsquelle
|
||||||
|
- **`/dev/urandom`** (Linux) oder `secrets.token_urlsafe()` (Python) — kryptographisch
|
||||||
|
sicher, nicht-deterministisch.
|
||||||
|
- NICHT: `random`-Modul, `time`-basiert, UUID (nicht krypto-sicher genug für Auth).
|
||||||
|
|
||||||
|
### Mindestentropie
|
||||||
|
- **≥ 256 Bit** (32 Bytes) pro Token. Empfehlung: 32 Bytes aus `/dev/urandom`,
|
||||||
|
base64url-kodiert → 43 Zeichen.
|
||||||
|
|
||||||
|
### Format / Länge
|
||||||
|
- **Format:** base64url (URL-sicher, kein `+`/`/`/`=`), 43 Zeichen bei 32 Bytes.
|
||||||
|
- **Länge:** 43 Zeichen (256 Bit Entropie).
|
||||||
|
- **Keine semantischen Token-Präfixe** (kein `save-`, `delete-`, `tolaria-` im Wert) —
|
||||||
|
Präfixe wären unnötige Information und könnten Scope-Leaks erzeugen. Der Scope wird
|
||||||
|
durch die ENV-Variable bestimmt, nicht durch den Tokenwert.
|
||||||
|
|
||||||
|
### Unabhängigkeit
|
||||||
|
- **SAVE und DELETE unabhängig generiert** (zwei separate `/dev/urandom`-Ziehungen).
|
||||||
|
- **Niemals gleicher Wert** (bei 256 Bit Entropie kollisionsfrei).
|
||||||
|
- **Keine Ableitung SAVE→DELETE** (kein Hash/Substring/Transformation).
|
||||||
|
- **Kein Master-Key** (kein gemeinsamer Seed, kein hierarchisches Schema).
|
||||||
|
|
||||||
|
### Nicht-Speicherung / Nicht-Ausgabe
|
||||||
|
- **Keine Shell-History** (kein `echo $TOKEN`, kein Token in interaktiven Befehlen).
|
||||||
|
- **Keine Ausgabe in Telegram / Chat / Logs / FINAL REPORT.**
|
||||||
|
- Token wird NUR in die Ziel-ENV/Secret-Datei geschrieben (Tolaria-Server-Container),
|
||||||
|
nie in Git, nie in Reports, nie in Test-Fixtures.
|
||||||
|
|
||||||
|
### Wer die Tokens bei AUTH.4 erzeugen darf
|
||||||
|
- **Nur Christian (Mensch)** — manuell auf dem VPS-Host, via `/dev/urandom` oder
|
||||||
|
`openssl rand -base64 32`, direkt in die Secret-Datei/Coolify-ENV geschrieben.
|
||||||
|
- **NICHT RQ/Hermes/Rain** — kein Agent erzeugt oder sieht die echten Tokens.
|
||||||
|
- Erzeugung erfolgt im Rahmen der AUTH.4-Deployment-Sequenz (Phase 9), nicht in
|
||||||
|
AUTH.3B.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6b. SERVER INJECTION CONTRACT (Phase 6) — AUTH.2 im Code verifiziert
|
||||||
|
|
||||||
|
### Exakte ENV-Namen (im Code verifiziert, `vite.config.ts` Z.35-39)
|
||||||
|
- `TOLARIA_VAULT_ROOT` → Default `/app/vault`
|
||||||
|
- `TOLARIA_SAVE_TOKEN` → `saveToken` (Scope SAVE)
|
||||||
|
- `TOLARIA_DELETE_TOKEN` → `deleteToken` (Scope DELETE)
|
||||||
|
|
||||||
|
### Verhalten (im Code verifiziert, `vault-write-guard.ts` Z.112-131)
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| SERVER_SAVE_ENV | `TOLARIA_SAVE_TOKEN` |
|
||||||
|
| SERVER_DELETE_ENV | `TOLARIA_DELETE_TOKEN` |
|
||||||
|
| MISSING_SAVE_BEHAVIOR | `expected.length === 0` → 401 "Write scope not configured" (fail-closed) |
|
||||||
|
| MISSING_DELETE_BEHAVIOR | dito (fail-closed) |
|
||||||
|
| EMPTY_TOKEN_BEHAVIOR | `|| ''` → leeres expected → 401 (fail-closed) |
|
||||||
|
| WRONG_SCOPE_BEHAVIOR | `constantTimeEqual(extracted, expected)` → falscher Scope → 401 "Invalid credentials" |
|
||||||
|
|
||||||
|
### Fail-closed bestätigt
|
||||||
|
- **Kein "Auth disabled because ENV missing".** Fehlende/leere Token-Konfiguration
|
||||||
|
→ mutierende Endpoints bleiben DENIED (401).
|
||||||
|
- Scope-Trennung: SAVE-Token autorisiert NUR SAVE; DELETE-Token NUR DELETE.
|
||||||
|
- Const-time-Vergleich (`constantTimeEqual`) gegen Timing-Angriffe.
|
||||||
|
- Auth-before-Mutation: bei Auth-Fehler wird Response gesendet, KEINE Mutation.
|
||||||
|
|
||||||
|
### Fazit Phase 6
|
||||||
|
- **SERVER_FAIL_CLOSED_STATUS = ENFORCED** (im Code verifiziert).
|
||||||
|
- Server erwartet exakt `TOLARIA_SAVE_TOKEN` + `TOLARIA_DELETE_TOKEN`.
|
||||||
|
- Kein Master-Token, keine Scope-Abschwächung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7b. CALLER INJECTION CONTRACT (Phase 7) — AUTH.3A im Code verifiziert
|
||||||
|
|
||||||
|
### ENV-Contract (im Code verifiziert, `rq_c5c.py` Z.100-101)
|
||||||
|
- `ENV_TOLARIA_SAVE_TOKEN = "TOLARIA_SAVE_TOKEN"`
|
||||||
|
- `ENV_TOLARIA_DELETE_TOKEN = "TOLARIA_DELETE_TOKEN"`
|
||||||
|
|
||||||
|
### Credential-Verwendung (im Code verifiziert)
|
||||||
|
| Caller | ENV | Verwendung | Scope |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C5C Writer (`C5CPropagator`) | `TOLARIA_SAVE_TOKEN` | `write()` Z.322: `self._require_token(self.save_token, "SAVE")` | SAVE |
|
||||||
|
| DeleteExecutor | `TOLARIA_DELETE_TOKEN` | `delete()` Z.352: `self._require_token(self.delete_token, "DELETE")` | DELETE |
|
||||||
|
| read/list | — | KEIN Credential | READ |
|
||||||
|
|
||||||
|
### Verifizierte Invarianten
|
||||||
|
- **SAVE-Prozess bekommt DELETE nicht:** `write()` nutzt NUR `self.save_token`.
|
||||||
|
- **DELETE-Prozess bekommt SAVE nicht:** `delete()` nutzt NUR `self.delete_token`.
|
||||||
|
- **read/list brauchen nichts:** `read()`/`list()` senden kein Bearer.
|
||||||
|
- **fehlendes Token => kein HTTP Write:** `_require_token` (Z.300-313) bricht lokal ab
|
||||||
|
(RC_AUTH_FAILURE), HTTP wird NICHT aufgerufen (fail-closed, AUTH.1 §4).
|
||||||
|
- **Retry behält Credential:** Client-Instanz hält Token über Retries hinweg.
|
||||||
|
- **Replay behält Credential:** `c5-delete-replay` nutzt denselben `_delete_executor`.
|
||||||
|
- **Token wird nicht persistiert:** kein Token in DB/Logs/Reports (AUTH.3A verifiziert).
|
||||||
|
|
||||||
|
### KRITISCHER BEFUND (Runtime-Separation)
|
||||||
|
- **Sind C5C Writer und DeleteExecutor heute getrennte Runtime-Prozesse/Boundaries?
|
||||||
|
NEIN.**
|
||||||
|
- Beide laufen im **selben Container** (`06a4a73573d0`, RQ/Hermes) und werden als
|
||||||
|
Library/CLI im selben Hermes-Gateway-Prozess aufgerufen.
|
||||||
|
- **Konsequenz:** Die Credential-Separation ist NUR logisch (verschiedene ENV-Namen,
|
||||||
|
verschiedene Klassen, verschiedene CLI-Commands). Wenn beide Tokens in die ENV
|
||||||
|
dieses Containers injiziert würden, hätte RQ/Hermes technischen Zugriff auf BEIDE.
|
||||||
|
- **Das wird NICHT als technische Separation behauptet.** Es ist explizit dokumentiert:
|
||||||
|
CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY (nicht technisch).
|
||||||
|
|
||||||
|
### Fazit Phase 7
|
||||||
|
- **CALLER_FAIL_CLOSED_STATUS = ENFORCED** (im Code verifiziert).
|
||||||
|
- **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY** (nicht technisch, da gleiche Boundary).
|
||||||
|
- Least Privilege im Code korrekt; Runtime-Separation fehlt (HARD STOP aus Phase 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8b. HUMAN APPROVAL INTERACTION (Phase 8) — AUTH4_WITH_OPEN_APPROVAL_GAP
|
||||||
|
|
||||||
|
### Kontext
|
||||||
|
- **HUMAN_APPROVAL_AUTHENTICITY_GAP = OPEN** (aus AUTH.3A, NICHT repariert).
|
||||||
|
Die DELETE-Approval erfüllt Invarianten A–N, aber die Provenance ist nicht
|
||||||
|
kryptographisch an Christian gebunden (frei wählbares CLI-Argument
|
||||||
|
`--approved-by`, `rq_c5_cli.py` Z.576).
|
||||||
|
- **DELETE-Execution (AUTH.3A, `rq_c5_delete.py` Z.192-251):** 4 Pre-Gates:
|
||||||
|
1. ObjectChange existiert + operation == DELETE
|
||||||
|
2. Commit-Status == ST_DELETE_APPROVED
|
||||||
|
3. Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad) ← **Human Gate**
|
||||||
|
4. Pre-Delete-Drift-Check (Ziel existiert noch)
|
||||||
|
|
||||||
|
### Wie interagiert das DELETE-Token mit dem Human Gate?
|
||||||
|
- Das DELETE-Token (AUTH.2/AUTH.3A) ist eine **zusätzliche, unabhängige Bedingung**
|
||||||
|
auf Transport-Ebene: `delete()` sendet Bearer DELETE-Token; Server verweigert ohne
|
||||||
|
gültiges Token (401).
|
||||||
|
- Das Human Approval Gate (Gate 3) ist eine **separate Bedingung auf
|
||||||
|
Anwendungs-Ebene**: `execute()` verweigert ohne persistierte, exakt passende
|
||||||
|
Approval.
|
||||||
|
- **DELETE ist nur möglich, wenn BEIDES erfüllt ist:**
|
||||||
|
A) gültiges DELETE-Credential (Server-Auth) UND
|
||||||
|
B) bestehendes C5 Human Approval Gate (Gate 3).
|
||||||
|
|
||||||
|
### Verschärft AUTH.4 das Authenticity-Gap?
|
||||||
|
- **NEIN.** AUTH.4 fügt eine zusätzliche Hürde (DELETE-Token) hinzu, die das
|
||||||
|
bestehende Gap NICHT verschärft, sondern die DELETE-Ausführung zusätzlich
|
||||||
|
absichert.
|
||||||
|
- Das Gap (Provenance nicht kryptographisch an Christian gebunden) bleibt bestehen,
|
||||||
|
wird aber durch das DELETE-Token NICHT schwächer. Ein Angreifer, der das
|
||||||
|
Authenticity-Gap ausnutzt (frei wählbares `--approved-by`), bräuchte ZUSÄTZLICH
|
||||||
|
das DELETE-Token, um tatsächlich zu löschen.
|
||||||
|
- **Kein Risiko, dass durch das DELETE-Token ein schwächeres Human Gate akzeptiert
|
||||||
|
wird.** Das Human Gate (Gate 3) bleibt unverändert ENFORCED.
|
||||||
|
|
||||||
|
### Klassifikation
|
||||||
|
**AUTH4_WITH_OPEN_APPROVAL_GAP = SAFE_FOR_SERVER_AUTH_ONLY**
|
||||||
|
|
||||||
|
Begründung:
|
||||||
|
- Die Einführung des DELETE-Tokens (Server-Auth) verschärft das Authenticity-Gap
|
||||||
|
NICHT — es fügt eine unabhängige, zusätzliche Hürde hinzu.
|
||||||
|
- **ABER:** DELETE_OPERATION_ACTIVATION (tatsächliches Löschen) sollte erst nach
|
||||||
|
Schließung des Gaps freigegeben werden, um das Risiko eines kombinierten Angriffs
|
||||||
|
(Gap + gestohlenes Token) zu minimieren.
|
||||||
|
- **SAFE_FOR_SERVER_AUTH_ONLY** bedeutet: Server-seitige Auth (AUTH.2) kann sicher
|
||||||
|
aktiviert werden, ohne das Gap zu verschärfen. Die DELETE-Operation selbst bleibt
|
||||||
|
bis zur Gap-Schließung zurückhaltend zu behandeln (siehe Phase 14 GO/NO-GO).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9b. DEPLOYMENT SEQUENCE DESIGN (Phase 9) — AUTH.4 atomar, KEIN Big-Bang
|
||||||
|
|
||||||
|
> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B.
|
||||||
|
|
||||||
|
### Kernfrage: Caller zuerst vs Server zuerst?
|
||||||
|
**Server zuerst (AUTH.2 aktivieren), dann Caller (AUTH.3A).**
|
||||||
|
|
||||||
|
Begründung (kein Zeitfenster mit ungeschützten Writes):
|
||||||
|
- Wenn der **Caller zuerst** das SAVE-Token erhält, aber der **Server noch ohne
|
||||||
|
Auth** läuft, dann sendet der Caller zwar ein Bearer-Token, aber der Server
|
||||||
|
ignoriert es (Produktion läuft aktuell OHNE AUTH.2) → **Writes bleiben
|
||||||
|
ungeschützt** (ungültiges Zeitfenster).
|
||||||
|
- Wenn der **Server zuerst** AUTH.2 aktiviert (fail-closed), dann verweigert er
|
||||||
|
ALLE Writes ohne gültiges Token. In diesem Moment haben die Caller noch KEIN
|
||||||
|
Token → **legitime C5-Writes fallen kurzzeitig aus** (kontrolliertes, kurzes
|
||||||
|
Fenster). Das ist akzeptabel, weil es FAIL-CLOSED ist (kein ungeschützter Write).
|
||||||
|
- **Kein Zeitfenster, in dem Writes ungeschützt bleiben:** Server-Auth zuerst
|
||||||
|
schließt das Loch sofort; Caller-Token werden danach injiziert.
|
||||||
|
- **Kein Zeitfenster, in dem DELETE ohne Human Gate möglich wird:** DELETE-Token
|
||||||
|
wird erst injiziert, wenn das Human Gate (Gate 3) bereits ENFORCED ist (AUTH.3A
|
||||||
|
Code ist deployed). DELETE bleibt doppelt abgesichert.
|
||||||
|
|
||||||
|
### Atomare AUTH.4-Reihenfolge (jeder Schritt einzeln, mit Gate)
|
||||||
|
|
||||||
|
| # | Schritt | Aktion | Gate/Verifikation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **PRECHECK** | Produktiver Source == Forgejo `edcb4902`; AUTH.2/AUTH.3A Code deployed; kein Token in ENV | `git diff` leer, ENV-Namen ABSENT |
|
||||||
|
| 2 | **BACKUP** | Vault + Compose + aktuelle ENV-Namen sichern (KEINE Tokenwerte) | Backup-Datei existiert |
|
||||||
|
| 3 | **TOKEN_GENERATION** | Christian erzeugt SAVE+DELETE-Token (Phase 5 Contract), schreibt in Secret-Datei/Coolify | Token in Ziel-ENV, NICHT in Git/Logs |
|
||||||
|
| 4 | **SERVER_SECRET_INJECTION** | `TOLARIA_SAVE_TOKEN` + `TOLARIA_DELETE_TOKEN` in Tolaria-Container-ENV (via .env/Coolify) | ENV-Namen PRESENT (Werte nicht prüfen) |
|
||||||
|
| 5 | **CALLER_SECRET_INJECTION** | SAVE-Token an C5C Writer, DELETE-Token an DeleteExecutor (NUR nach Boundary-Fix, Phase 3) | Getrennte Boundaries ODER HARD STOP |
|
||||||
|
| 6 | **CODE_DEPLOY** | AUTH.2-Source (edcb4902) in Produktion deployen (Container-Restart) | Server startet, Read funktioniert |
|
||||||
|
| 7 | **SERVER_RESTART** | Tolaria-Container neu starten (liest neue ENV) | Healthcheck OK |
|
||||||
|
| 8 | **READ_SMOKE** | `GET /api/vault/ping`, `/list`, `/content` ohne Token → 200 | Read funktioniert |
|
||||||
|
| 9 | **UNAUTH_WRITE_NEGATIVE_TEST** | `POST /save` ohne Token → 401 (fail-closed) | Write DENIED |
|
||||||
|
| 10 | **WRONG_SCOPE_NEGATIVE_TEST** | `POST /save` mit DELETE-Token → 401; `POST /delete` mit SAVE-Token → 401 | Scope-Trennung |
|
||||||
|
| 11 | **AUTHORIZED_SAVE_TEST** | `POST /save` mit SAVE-Token → 200 (isolierte Fixture) | SAVE funktioniert |
|
||||||
|
| 12 | **DELETE_GATE_NEGATIVE_TEST** | `POST /delete` mit DELETE-Token aber OHNE Human Approval → DENIED | Human Gate ENFORCED |
|
||||||
|
| 13 | **AUTHORIZED_DELETE_TEST** | `POST /delete` mit DELETE-Token + Human Approval → 200 (NUR falls freigabefähig) | DELETE funktioniert |
|
||||||
|
| 14 | **POST_DEPLOY_CHECK** | Read + Write + Scope + kein Token in Logs/inspect | Alle Checks grün |
|
||||||
|
| 15 | **FRESH_CHECKER** | Unabhängiger Checker verifiziert alle Gates | PASS |
|
||||||
|
|
||||||
|
### Wichtige Design-Entscheidungen
|
||||||
|
- **Kein Big-Bang:** Jeder Schritt hat ein eigenes Gate; bei Fehler → Rollback
|
||||||
|
(Phase 10), nicht weiter.
|
||||||
|
- **Schritt 5 (CALLER_SECRET_INJECTION) ist BLOCKED** bis die Boundary-Frage aus
|
||||||
|
Phase 3 gelöst ist (C5-Caller in getrennten Container ODER RQ-undurchdringbare
|
||||||
|
Injektion). Ohne das bleibt RQ credential-free NICHT erreichbar.
|
||||||
|
- **Schritt 13 (AUTHORIZED_DELETE_TEST) ist CONDITIONALLY_READY** — nur falls
|
||||||
|
DELETE-Operation freigabefähig ist (siehe Phase 14, Gap-Berücksichtigung).
|
||||||
|
- **Server zuerst** minimiert das ungeschützte Write-Fenster auf null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10b. ROLLBACK DESIGN (Phase 10) — FAIL CLOSED, Write-Degraded-Mode
|
||||||
|
|
||||||
|
> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B.
|
||||||
|
|
||||||
|
### Grundprinzip
|
||||||
|
- **FAIL CLOSED bevorzugen.** Bei Credential-/Auth-Problem NICHT automatisch auf
|
||||||
|
unauthentifizierte Writes zurückrollen.
|
||||||
|
- **Ein Rollback darf NICHT bedeuten:** "Auth ausschalten und unauthentifizierte
|
||||||
|
Writes wieder erlauben", wenn dadurch die bereits identifizierte CRITICAL Gap
|
||||||
|
(Produktion läuft aktuell OHNE Auth) wieder geöffnet wird.
|
||||||
|
- **Bevorzugter Security-Rollback = WRITE_DEGRADED_MODE:**
|
||||||
|
- **READ AVAILABLE** (Read-Endpunkte funktionieren weiter)
|
||||||
|
- **WRITE DISABLED** (mutierende Endpunkte bleiben DENIED)
|
||||||
|
|
||||||
|
### Exakte Rollback-Pfade
|
||||||
|
|
||||||
|
| Fall | Symptom | Rollback-Aktion | Security-Position |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A) Server startet nicht** | Container-Crash nach AUTH.2-Deploy | Source auf `b5a5e4bd` (Basisstand) zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED (kein Write ohne Auth) |
|
||||||
|
| **B) Read-Endpunkte kaputt** | `/ping`, `/list`, `/content` 500 | Source auf `b5a5e4bd` zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED |
|
||||||
|
| **C) C5C SAVE schlägt fehl** | SAVE-Writes 401/500 | SAVE-Token prüfen/erneuern; NICHT Auth deaktivieren | WRITE_DEGRADED (SAVE disabled, READ available) |
|
||||||
|
| **D) DELETE-Caller schlägt fehl** | DELETE-Writes 401/500 | DELETE-Token prüfen/erneuern; NICHT Human Gate deaktivieren | WRITE_DEGRADED (DELETE disabled, READ available) |
|
||||||
|
| **E) Token falsch injiziert** | Wrong-Scope 401 | Token korrekt injizieren; NICHT Auth deaktivieren | FAIL CLOSED |
|
||||||
|
| **F) Token-Leak vermutet** | Token in Logs/inspect/Repo | Token SOFORT rotieren (Phase 11); NICHT Auth deaktivieren | FAIL CLOSED |
|
||||||
|
| **G) Falscher Scope** | SAVE-Token autorisiert DELETE (o. umgekehrt) | Scope-Trennung prüfen; Token neu generieren | FAIL CLOSED |
|
||||||
|
| **H) Regression nach Restart** | Read/Write unerwartet | Source auf `b5a5e4bd` zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED |
|
||||||
|
|
||||||
|
### WRITE_DEGRADED_MODE (bevorzugter Security-Rollback)
|
||||||
|
- **READ AVAILABLE:** `/api/vault/ping`, `/list`, `/content`, `/all-content`,
|
||||||
|
`/entry`, `/search` funktionieren (kein Token nötig).
|
||||||
|
- **WRITE DISABLED:** `/api/vault/save`, `/rename`, `/rename-filename`, `/delete`,
|
||||||
|
`/command` bleiben DENIED (401) — fail-closed, weil Token-Konfiguration entfernt
|
||||||
|
oder ungültig ist.
|
||||||
|
- **Wie erreicht:** ENV-Tokens aus Tolaria-Container entfernen (oder auf ungültig
|
||||||
|
setzen) + Restart. Server bleibt fail-closed (leeres expected → 401).
|
||||||
|
- **WICHTIG:** Das ist KEIN "Auth ausschalten". Es ist "Auth auf DENIED-ALL
|
||||||
|
setzen" — Writes sind blockiert, nicht ungeschützt.
|
||||||
|
|
||||||
|
### Warum NICHT auf unauthentifizierte Writes zurückrollen
|
||||||
|
- Die Produktion läuft aktuell OHNE Auth (CRITICAL Gap, verifiziert in Phase 1).
|
||||||
|
- Ein Rollback auf "Auth aus" würde dieses Gap wieder öffnen und Writes ungeschützt
|
||||||
|
lassen.
|
||||||
|
- WRITE_DEGRADED_MODE hält Writes geschützt (DENIED), während Read verfügbar bleibt
|
||||||
|
— das ist die sichere Zwischenposition bis zur Fehlerbehebung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11b. TOKEN ROTATION / REVOCATION (Phase 11) — SAVE/DELETE getrennt
|
||||||
|
|
||||||
|
> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B.
|
||||||
|
|
||||||
|
### Unterstützt AUTH.2 nur einen Token pro Scope?
|
||||||
|
- **JA.** `VaultAuthConfig` (`vite.config.ts` Z.36-39) hält `saveToken` und
|
||||||
|
`deleteToken` als **einzelne Strings** (kein Array, keine Multi-Token-Unterstützung).
|
||||||
|
- `authorizeVaultWrite` (Z.120-126) vergleicht gegen genau EINEN erwarteten Token
|
||||||
|
pro Scope.
|
||||||
|
- **Konsequenz:** Zero-downtime-Rotation (zwei gültige Tokens gleichzeitig) ist
|
||||||
|
mit AUTH.2 NICHT möglich. Rotation erfordert ein kurzes Write-Downtime-Fenster.
|
||||||
|
|
||||||
|
### SAVE-Rotation (minimales Write-Downtime-Fenster)
|
||||||
|
1. Neues SAVE-Token erzeugen (Phase 5 Contract).
|
||||||
|
2. Neues Token in Tolaria-Container-ENV schreiben (ersetzt altes).
|
||||||
|
3. Tolaria-Container neu starten (liest neues Token).
|
||||||
|
4. C5C Writer erhält neues SAVE-Token (nach Boundary-Fix, Phase 3).
|
||||||
|
5. Altes SAVE-Token ist sofort ungültig (Server vergleicht nur gegen neues).
|
||||||
|
6. **Write-Downtime-Fenster:** zwischen Schritt 3 und 4 sendet C5C Writer noch
|
||||||
|
altes Token → 401. Fenster = Zeit bis C5C Writer aktualisiert ist.
|
||||||
|
7. **Verifikation:** `POST /save` mit neuem Token → 200; mit altem → 401.
|
||||||
|
|
||||||
|
### DELETE-Rotation (analog, getrennt)
|
||||||
|
1. Neues DELETE-Token erzeugen.
|
||||||
|
2. In Tolaria-Container-ENV schreiben (ersetzt altes).
|
||||||
|
3. Tolaria-Container neu starten.
|
||||||
|
4. DeleteExecutor erhält neues DELETE-Token.
|
||||||
|
5. Altes DELETE-Token sofort ungültig.
|
||||||
|
6. **Verifikation:** `POST /delete` mit neuem Token + Human Approval → 200; mit
|
||||||
|
altem → 401.
|
||||||
|
|
||||||
|
### Laufende Retries / queued Jobs
|
||||||
|
- **Retries:** Ein laufender Retry mit altem Token schlägt nach Rotation fehl (401).
|
||||||
|
Der Retry muss das neue Token verwenden (Client-Instanz neu konstruieren).
|
||||||
|
- **Queued Jobs:** C5-Queue-Einträge, die vor Rotation geplant wurden, verwenden
|
||||||
|
beim Ausführen das aktuelle Token (Client wird zur Laufzeit konstruiert). Nach
|
||||||
|
Rotation nutzen sie das neue Token.
|
||||||
|
- **Empfehlung:** Rotation in einem Wartungsfenster durchführen, wenn keine
|
||||||
|
kritischen Writes anstehen.
|
||||||
|
|
||||||
|
### DELETE-Revocation (unabhängig von SAVE)
|
||||||
|
- **DELETE-Revocation muss unabhängig von SAVE möglich sein:** Ja — DELETE-Token
|
||||||
|
ist eine separate ENV-Variable (`TOLARIA_DELETE_TOKEN`). Entfernen/ersetzen
|
||||||
|
dieser Variable + Restart widerruft DELETE, ohne SAVE zu beeinflussen.
|
||||||
|
- **SAVE-Revocation analog:** `TOLARIA_SAVE_TOKEN` entfernen/ersetzen + Restart
|
||||||
|
widerruft SAVE, ohne DELETE zu beeinflussen.
|
||||||
|
- **Revocation = sofort wirksam** nach Container-Restart (Server liest ENV beim
|
||||||
|
Start; kein Cache).
|
||||||
|
|
||||||
|
### Welche Komponenten müssen restart/reload?
|
||||||
|
- **Tolaria-Server-Container:** Restart nötig (liest ENV beim Start).
|
||||||
|
- **C5C Writer / DeleteExecutor:** Client-Instanz neu konstruieren (neues Token).
|
||||||
|
Kein Container-Restart nötig, wenn Token via ENV/Config injiziert wird und der
|
||||||
|
Prozess neu gestartet wird.
|
||||||
|
|
||||||
|
### Wie wird Rotation verifiziert?
|
||||||
|
- **Positiv:** Write mit neuem Token → 200.
|
||||||
|
- **Negativ:** Write mit altem Token → 401 (fail-closed).
|
||||||
|
- **Scope:** SAVE-Token autorisiert nicht DELETE und umgekehrt.
|
||||||
|
- **Kein Token-Leak:** neues Token nicht in Logs/inspect/Repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12b. BACKUP / RECOVERY (Phase 12) — Secret-Handling
|
||||||
|
|
||||||
|
> ⚠️ NUR DESIGN. KEINE Backup-Konfiguration ändern in AUTH.3B.
|
||||||
|
|
||||||
|
### Werden Secret-Werte in normalen Backups erfasst?
|
||||||
|
- **NEIN.** Das Tolaria-Backup-Skript (`/opt/data/bin/tolaria_backup.py`) sichert
|
||||||
|
NUR Vault-Content (`.md`-Dateien) und App-Source über die read-only API
|
||||||
|
(`/api/vault/list`, `/api/vault/content`). Es sichert KEINE ENV-Variablen,
|
||||||
|
KEINE Compose-Datei, KEINE Secret-Dateien.
|
||||||
|
- **Sollten sie enthalten sein?** NEIN. Secrets gehören NICHT in Backups. Sie
|
||||||
|
werden separat (außerhalb des Backup-Pfads) verwaltet und bei Restore neu
|
||||||
|
injiziert.
|
||||||
|
|
||||||
|
### Wie wird Disaster Recovery durchgeführt?
|
||||||
|
1. Vault-Content aus Backup wiederherstellen (via `tolaria_backup.py` oder
|
||||||
|
Vault-Git-Restore).
|
||||||
|
2. App-Source aus Forgejo (`edcb4902`) neu deployen (rebuildbar, Forgejo=SoT).
|
||||||
|
3. **Secrets NEU injizieren** (nicht aus Backup): Christian erzeugt frische
|
||||||
|
SAVE/DELETE-Tokens (Phase 5 Contract) und schreibt sie in die Container-ENV.
|
||||||
|
|
||||||
|
### Müssen Tokens nach Restore rotiert werden?
|
||||||
|
- **JA, zwingend.** Nach einem Disaster Recovery werden frische Tokens erzeugt
|
||||||
|
(nicht die alten aus dem Backup). Das verhindert, dass alte, möglicherweise
|
||||||
|
kompromittierte Tokens weiterleben.
|
||||||
|
|
||||||
|
### Können alte Backups alte gültige Tokens enthalten?
|
||||||
|
- **NEIN** (aktuell): Das Backup-Skript sichert keine Secrets. Aber falls in
|
||||||
|
Zukunft ein Backup die Compose-Datei oder ENV mit aufnimmt, könnten alte Tokens
|
||||||
|
enthalten sein.
|
||||||
|
|
||||||
|
### Wie verhindern wir Secret-Restore aus veraltetem Backup?
|
||||||
|
- **Backup-Skript sichert keine Secrets** (verifiziert, Z.17 "Keine Secrets im
|
||||||
|
Manifest/Log").
|
||||||
|
- **Restore-Prozedur injiziert IMMER frische Tokens** (nie aus Backup).
|
||||||
|
- **Empfehlung:** Falls ein Backup je Compose/ENV aufnimmt, Secrets vor dem
|
||||||
|
Backup strikt ausschließen (`.gitignore`-artige Exklusion) und nach Restore
|
||||||
|
rotieren.
|
||||||
|
|
||||||
|
### Empfehlung (dokumentiert, KEINE Änderung)
|
||||||
|
- Secrets NIE in Backups aufnehmen.
|
||||||
|
- Nach jedem Restore frische Tokens erzeugen.
|
||||||
|
- Backup-Manifest/Logs secret-frei halten (bereits der Fall).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13b. ISOLATED DEPLOYMENT TEST PLAN (Phase 13) — AUTH.4
|
||||||
|
|
||||||
|
> ⚠️ NUR TESTPLAN. KEINE produktiven Mutationsprobes in AUTH.3B.
|
||||||
|
> Alle Tests werden in AUTH.4 in isolierter Umgebung (Fixture-Vault, synthetische
|
||||||
|
> Tokens) ausgeführt, NICHT gegen Produktion.
|
||||||
|
|
||||||
|
| ID | Test | Erwartung | Typ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T1 | READ ohne Token | `/ping`, `/list`, `/content` → 200 | Positiv |
|
||||||
|
| T2 | SAVE ohne Token | `POST /save` → 401 (fail-closed) | Negativ |
|
||||||
|
| T3 | SAVE wrong token | `POST /save` mit falschem Token → 401 | Negativ |
|
||||||
|
| T4 | SAVE DELETE-token | `POST /save` mit DELETE-Token → 401 (Scope) | Negativ |
|
||||||
|
| T5 | SAVE korrektes SAVE-token | `POST /save` mit SAVE-Token → 200 | Positiv |
|
||||||
|
| T6 | DELETE ohne Token | `POST /delete` → 401 (fail-closed) | Negativ |
|
||||||
|
| T7 | DELETE wrong token | `POST /delete` mit falschem Token → 401 | Negativ |
|
||||||
|
| T8 | DELETE SAVE-token | `POST /delete` mit SAVE-Token → 401 (Scope) | Negativ |
|
||||||
|
| T9 | DELETE korrektes Token, ohne Human Approval | → DENIED (Gate 3) | Negativ |
|
||||||
|
| T10 | DELETE Approval, aber ohne Token | → 401 (fail-closed) | Negativ |
|
||||||
|
| T11 | DELETE Token + Approval | → 200 NUR falls ausdrücklich freigegeben | Positiv |
|
||||||
|
| T12 | missing server ENV | Server startet, Writes DENIED (fail-closed) | Negativ |
|
||||||
|
| T13 | missing caller ENV | Caller bricht vor HTTP ab (fail-closed) | Negativ |
|
||||||
|
| T14 | Restart erhält Security State | Nach Restart weiterhin fail-closed | Positiv |
|
||||||
|
| T15 | kein Token in Logs | Logs secret-frei | Negativ |
|
||||||
|
| T16 | kein Token in inspect | `docker inspect` secret-frei (soweit Architektur) | Negativ |
|
||||||
|
| T17 | kein Token in Repo | Git-History secret-frei | Negativ |
|
||||||
|
| T18 | kein Token im Vault | Vault-Content secret-frei | Negativ |
|
||||||
|
| T19 | kein Token in DB | C5-DB secret-frei | Negativ |
|
||||||
|
| T20 | Retry sicher | Retry behält Credential, kein Leak | Positiv |
|
||||||
|
| T21 | Replay sicher | Replay behält Credential, kein Leak | Positiv |
|
||||||
|
| T22 | READ während Write-Degraded-Mode | Read 200, Write DENIED | Positiv |
|
||||||
|
| T23 | SAVE/DELETE getrennt widerrufbar | Revocation eines Scopes beeinflusst anderen nicht | Positiv |
|
||||||
|
| T24 | Path traversal weiterhin denied | `../` → 401/400 | Negativ |
|
||||||
|
| T25 | Symlink escape weiterhin denied | Symlink → 401/400 | Negativ |
|
||||||
|
| T26 | `/api/vault/command` kann Auth nicht umgehen | Command ohne Token → 401 | Negativ |
|
||||||
|
|
||||||
|
### Test-Ausführungsregeln (AUTH.4)
|
||||||
|
- Alle Tests in isolierter Umgebung (Fixture-Vault, synthetische Tokens
|
||||||
|
`auth4-...`), NICHT gegen Produktion.
|
||||||
|
- T11 (DELETE Token + Approval) NUR wenn DELETE-Operation freigabefähig ist
|
||||||
|
(siehe Phase 14).
|
||||||
|
- Keine produktiven Mutationsprobes in AUTH.3B.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14b. AUTH.4 GO/NO-GO MATRIX (Phase 14)
|
||||||
|
|
||||||
|
> ⚠️ NUR BEWERTUNG. KEINE Aktivierung in AUTH.3B.
|
||||||
|
|
||||||
|
| Punkt | Status | Konkrete Blocker |
|
||||||
|
|---|---|---|
|
||||||
|
| **SERVER_AUTH_DEPLOYMENT** | **CONDITIONALLY_READY** | AUTH.2-Code (edcb4902) ist fertig + getestet (70/70, Fresh Checker 17/17). Blocker: produktiver Source muss == Forgejo `edcb4902` sein; ENV-Tokens müssen injiziert werden; Container-Restart nötig. Kein Code-Blocker. |
|
||||||
|
| **SAVE_ACTIVATION** | **CONDITIONALLY_READY** | AUTH.3A-Code (a11c1bb) fertig + getestet (19/19, 318/318). Blocker: SAVE-Token muss an C5C Writer injiziert werden; **Boundary-Frage (Phase 3) muss gelöst sein** — sonst hätte RQ Zugriff auf SAVE-Token. |
|
||||||
|
| **DELETE_CREDENTIAL_ACTIVATION** | **BLOCKED** | DELETE-Token-Injektion an DeleteExecutor erfordert getrennte Boundary (Phase 3). Aktuell laufen SAVE+DELETE im selben Container → RQ hätte Zugriff auf BEIDE Tokens. **HARD STOP bis Boundary-Fix.** |
|
||||||
|
| **DELETE_OPERATION_ACTIVATION** | **BLOCKED** | Zusätzlich zum Boundary-Fix: HUMAN_APPROVAL_AUTHENTICITY_GAP ist OPEN. DELETE-Operation erst nach Gap-Schließung freigeben (kombinierter Angriff Gap+Token). |
|
||||||
|
| **HERMES_READ_ONLY_AUTONOMY** | **CONDITIONALLY_READY** | Read-Endpunkte brauchen kein Token; RQ bleibt credential-free für Read. Blocker: keine (Read ist bereits möglich). |
|
||||||
|
| **HERMES_BOUNDED_AUTONOMY** | **BLOCKED** | Erfordert SAVE/DELETE-Aktivierung, die wiederum Boundary-Fix + Gap-Schließung voraussetzt. |
|
||||||
|
|
||||||
|
### Kern-Blocker (zusammenfassend)
|
||||||
|
1. **CREDENTIAL_SEPARATION (Phase 3):** SAVE+DELETE laufen im selben Container
|
||||||
|
(`06a4a73573d0`). RQ credential-free ist technisch NICHT erreichbar, solange
|
||||||
|
beide Tokens in die ENV dieses Containers injiziert würden. → **HARD STOP für
|
||||||
|
DELETE_CREDENTIAL_ACTIVATION.**
|
||||||
|
2. **HUMAN_APPROVAL_AUTHENTICITY_GAP (OPEN):** DELETE-Operation erst nach
|
||||||
|
Gap-Schließung. → **BLOCKED für DELETE_OPERATION_ACTIVATION.**
|
||||||
|
3. **SERVER_AUTH_DEPLOYMENT** und **SAVE_ACTIVATION** sind CONDITIONALLY_READY
|
||||||
|
(Code fertig), aber SAVE_ACTIVATION hängt an der Boundary-Frage.
|
||||||
|
|
||||||
|
### Empfohlene Reihenfolge (AUTH.4, nach Freigabe)
|
||||||
|
1. SERVER_AUTH_DEPLOYMENT (AUTH.2 aktivieren) — sicher, verschärft Gap nicht.
|
||||||
|
2. Boundary-Fix (C5-Caller in getrennten Container ODER RQ-undurchdringbare
|
||||||
|
Injektion) — VORAUSSETZUNG für SAVE/DELETE-Credential-Aktivierung.
|
||||||
|
3. SAVE_ACTIVATION (nach Boundary-Fix).
|
||||||
|
4. Gap-Schließung (separate Freigabe).
|
||||||
|
5. DELETE_CREDENTIAL_ACTIVATION + DELETE_OPERATION_ACTIVATION (nach 2+4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. INCIDENT (AUTH.3B, 2026-08-27)
|
||||||
|
|
||||||
|
**INCIDENT_OCCURRED = TRUE** (während Phase 1 Discovery)
|
||||||
|
- Versehentlicher mutierender POST `POST /api/vault/save` gegen Produktion
|
||||||
|
(`_auth3b_probe.md`, Inhalt "x") — Verstoß gegen die verbindliche Incident-Regel
|
||||||
|
(NO MUTATING HTTP METHOD PROBES).
|
||||||
|
- **INCIDENT_RECOVERED = TRUE** — Datei sofort via `POST /api/vault/delete` entfernt,
|
||||||
|
Read-Back bestätigt: Datei existiert nicht mehr.
|
||||||
|
- **PERMANENT_DAMAGE = NONE VERIFIED**
|
||||||
|
- **ROOT_CAUSE = unsafe mutating endpoint probe during read-only discovery**
|
||||||
|
- **BESTÄTIGT:** Produktion läuft OHNE AUTH.2 (save ohne Token → 200, kein 401).
|
||||||
|
- **LEHRE:** Keine weiteren mutierenden POSTs. Nur read-only GET/Code-Reading.
|
||||||
546
tolaria/tolaria-write-auth/AUTH3C_DESIGN.md
Normal file
546
tolaria/tolaria-write-auth/AUTH3C_DESIGN.md
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
# AUTH.3C — C5 EXECUTOR RUNTIME ISOLATION DESIGN
|
||||||
|
|
||||||
|
**Status:** DESIGN + VERIFIED DEPLOYMENT PLAN (STRICT READ-ONLY, KEIN produktiver Umbau)
|
||||||
|
**Datum:** 2026-08-27
|
||||||
|
**Mission Type:** STRICT READ-ONLY DISCOVERY + ARCHITECTURE DESIGN + ISOLATED VALIDATION
|
||||||
|
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B = COMPLETE
|
||||||
|
|
||||||
|
> ⚠️ Dies ist ein **Design-Dokument**. Es wird KEIN produktiver Umbau ausgeführt,
|
||||||
|
> kein Container erstellt, keine Compose/Network/Volume/DB geändert, kein echtes
|
||||||
|
> Credential erzeugt, keine ENV geändert, kein Deployment, keine produktive
|
||||||
|
> Tolaria-Mutationsprobe. Bei Zweifel: FAIL CLOSED + HARD STOP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CURRENT EXECUTION FLOW (Phase 1) — rekonstruiert
|
||||||
|
|
||||||
|
### 1a. SAVE-Pfad: RQ/Hermes → C5 CLI → C5CPropagator → TolariaClient.write()
|
||||||
|
|
||||||
|
```
|
||||||
|
RQ/Hermes (Container 06a4a73573d0, User hermes uid 10010)
|
||||||
|
│
|
||||||
|
├─ [Entry Point] rq_c5_cli.py (argparse CLI, manuell aufgerufen)
|
||||||
|
│ KEIN CLI-Command führt C5CPropagator mit save_token aus (verifiziert)
|
||||||
|
│
|
||||||
|
├─ [Library] rq_c5c.py C5CPropagator (Z.497)
|
||||||
|
│ __init__(store, reader, client=None, ...) → client = client or TolariaClient()
|
||||||
|
│ Default: credential-frei (kein save_token)
|
||||||
|
│ write() → self.client.write(vault_path, after_content) (Z.638)
|
||||||
|
│
|
||||||
|
└─ [Library] rq_c5c.py TolariaClient.write() (Z.322)
|
||||||
|
token = self._require_token(self.save_token, "SAVE") # fail-closed
|
||||||
|
→ POST /api/vault/save (Bearer save_token)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boundary-Fakten (SAVE):**
|
||||||
|
- **Process Boundary:** C5CPropagator läuft im RQ/Hermes-Prozess (kein separater Prozess).
|
||||||
|
- **Container Boundary:** RQ-Container `06a4a73573d0` (User `hermes`, uid 10010, gid 10000).
|
||||||
|
- **Filesystem Boundary:** `/opt/data` (RQ-Container-Dateisystem); C5-DB `/opt/data/c5c_live/c5a.db`.
|
||||||
|
- **ENV Boundary:** `TOLARIA_SAVE_TOKEN` ABSENT im RQ-Container (verifiziert). `C5A_DB` steuert DB-Pfad.
|
||||||
|
- **Network Boundary:** Tolaria `187.124.31.123:5173` (auch 10.0.4.1/10.0.9.1); Forgejo 10.0.4.1:3000/10.0.9.1:3000.
|
||||||
|
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Commit-State, ObjectChanges, Approvals, Ledger).
|
||||||
|
- **Forgejo-Zugriff:** GitReader liest Repo `nexo312/trading-system-docs` (Forgejo-Git-Push-Key).
|
||||||
|
- **Tolaria-Zugriff:** `write()` (SAVE) via `TolariaClient`.
|
||||||
|
- **Approval-DB-Zugriff:** C5AStore (delete_approvals-Tabelle) — SAVE-Pfad liest/schreibt sie nicht aktiv.
|
||||||
|
- **Benötigte Inputs:** Commit-SHA, ObjectChanges, Source-/Provenance-Daten aus Forgejo.
|
||||||
|
- **Erzeugte Outputs:** Tolaria-SAVE, C5-State-Update (PROPAGATING→VERIFYING→UPDATING_SEARCH).
|
||||||
|
|
||||||
|
### 1b. DELETE-Pfad: RQ/Hermes → C5 CLI → DeleteExecutor → Approval Validation → TolariaClient.delete()
|
||||||
|
|
||||||
|
```
|
||||||
|
RQ/Hermes (Container 06a4a73573d0)
|
||||||
|
│
|
||||||
|
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_approve (Z.355) → persistiert Human-Approval
|
||||||
|
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_execute (Z.424) → führt DELETE aus
|
||||||
|
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_replay (Z.458) → idempotenter Replay
|
||||||
|
│
|
||||||
|
├─ [Helper] rq_c5_cli.py _delete_executor (Z.344)
|
||||||
|
│ TolariaClient(base_url, delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN))
|
||||||
|
│ → liest NUR DELETE-Token (kein SAVE-Token)
|
||||||
|
│
|
||||||
|
├─ [Library] rq_c5_delete.py DeleteExecutor (Z.83)
|
||||||
|
│ __init__(store, client=None) → client = client or TolariaClient() (Default credential-frei)
|
||||||
|
│ execute(commit_sha) (Z.192):
|
||||||
|
│ Gate 1: ObjectChange existiert, operation == DELETE
|
||||||
|
│ Gate 2: Commit-Status == ST_DELETE_APPROVED
|
||||||
|
│ Gate 3: Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad)
|
||||||
|
│ Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch)
|
||||||
|
│ → _do_delete() → self.client.delete() (DELETE)
|
||||||
|
│
|
||||||
|
└─ [Library] rq_c5c.py TolariaClient.delete() (Z.~352)
|
||||||
|
token = self._require_token(self.delete_token, "DELETE") # fail-closed
|
||||||
|
→ POST /api/vault/delete (Bearer delete_token)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boundary-Fakten (DELETE):**
|
||||||
|
- **Process Boundary:** DeleteExecutor läuft im RQ/Hermes-Prozess (kein separater Prozess).
|
||||||
|
- **Container Boundary:** RQ-Container `06a4a73573d0` — **SELBER Container wie SAVE**.
|
||||||
|
- **Filesystem Boundary:** `/opt/data`; C5-DB `/opt/data/c5c_live/c5a.db`.
|
||||||
|
- **ENV Boundary:** `TOLARIA_DELETE_TOKEN` ABSENT im RQ-Container (verifiziert).
|
||||||
|
- **Network Boundary:** Tolaria `187.124.31.123:5173`.
|
||||||
|
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Approvals, Ledger, Commit-State).
|
||||||
|
- **Forgejo-Zugriff:** GitReader (Provenance-/Object-Daten).
|
||||||
|
- **Tolaria-Zugriff:** `delete()` (DELETE) via `TolariaClient`.
|
||||||
|
- **Approval-DB-Zugriff:** C5AStore delete_approvals-Tabelle (lesen + USED markieren).
|
||||||
|
- **Benötigte Inputs:** Commit-SHA, persistierte Approval, ObjectChange, Nonce.
|
||||||
|
- **Erzeugte Outputs:** Tolaria-DELETE, Approval→USED, Commit→ST_UPDATING_SEARCH.
|
||||||
|
|
||||||
|
### 1c. Kernbefund (bestätigt AUTH.3B)
|
||||||
|
**SAVE- und DELETE-Caller laufen im SELBEN Prozess/Container** (`rq_c5_cli.py`, RQ-Container
|
||||||
|
`06a4a73573d0`). **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY.** Sobald Credentials
|
||||||
|
produktiv in denselben Container injiziert würden, wäre RQ_SAVE_ACCESS/DELETE_ACCESS
|
||||||
|
technisch NICHT mehr NO. → AUTH.4 BLOCKED.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MINIMUM EXECUTOR CAPABILITIES (Phase 2) — Least Privilege
|
||||||
|
|
||||||
|
### SAVE EXECUTOR (maximal benötigt)
|
||||||
|
| Capability | Klassifikation |
|
||||||
|
|---|---|
|
||||||
|
| C5 Work Package/Input lesen | REQUIRED |
|
||||||
|
| Source-/Provenance-Daten lesen (Forgejo) | REQUIRED |
|
||||||
|
| Tolaria READ (falls erforderlich) | REQUIRED (Verifikation) |
|
||||||
|
| Tolaria SAVE | REQUIRED |
|
||||||
|
| C5 State Updates (c5a.db) | REQUIRED |
|
||||||
|
| Tolaria DELETE | NOT_REQUIRED |
|
||||||
|
| Forgejo Admin | NOT_REQUIRED |
|
||||||
|
| Docker | NOT_REQUIRED |
|
||||||
|
| SSH | NOT_REQUIRED |
|
||||||
|
| Host Root | NOT_REQUIRED |
|
||||||
|
| DELETE Credential | NOT_REQUIRED |
|
||||||
|
| Search-Rebuild Credential | NOT_REQUIRED (sofern nicht zwingend) |
|
||||||
|
|
||||||
|
### DELETE EXECUTOR (maximal benötigt)
|
||||||
|
| Capability | Klassifikation |
|
||||||
|
|---|---|
|
||||||
|
| Konkreten Delete-Auftrag lesen | REQUIRED |
|
||||||
|
| Persistierte Approval lesen/verifizieren | REQUIRED |
|
||||||
|
| Object/Commit/Nonce prüfen | REQUIRED |
|
||||||
|
| Tolaria DELETE | REQUIRED |
|
||||||
|
| Delete Ledger aktualisieren | REQUIRED |
|
||||||
|
| Tolaria SAVE | NOT_REQUIRED |
|
||||||
|
| Forgejo Admin | NOT_REQUIRED |
|
||||||
|
| Docker | NOT_REQUIRED |
|
||||||
|
| SSH | NOT_REQUIRED |
|
||||||
|
| Host Root | NOT_REQUIRED |
|
||||||
|
| SAVE Credential | NOT_REQUIRED |
|
||||||
|
|
||||||
|
> **UNKNOWN darf nicht als REQUIRED angenommen werden.** Alle oben als REQUIRED
|
||||||
|
> markierten Capabilities sind aus dem Code verifiziert (nicht angenommen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ISOLATION OPTIONS (Phase 3) — Bewertung
|
||||||
|
|
||||||
|
> ⚠️ NUR BEWERTUNG. KEINE Container/Networks/Volumes erstellen.
|
||||||
|
|
||||||
|
### OPTION A — Ein separater C5-Executor-Container mit SAVE+DELETE
|
||||||
|
- **SECURITY_BOUNDARY:** RQ↔Executor getrennt; aber SAVE+DELETE im selben Executor.
|
||||||
|
- **RQ_SECRET_VISIBILITY:** RQ kann Executor-Secrets nicht lesen (Container-Boundary).
|
||||||
|
- **SAVE_DELETE_ISOLATION:** **NEIN** — SAVE und DELETE im selben Container.
|
||||||
|
- **FILESYSTEM_ISOLATION:** RQ↔Executor getrennt; SAVE/DELETE gemeinsam.
|
||||||
|
- **ENV_ISOLATION:** RQ↔Executor getrennt; SAVE/DELETE-ENV gemeinsam.
|
||||||
|
- **NETWORK_ISOLATION:** Executor-Netzwerk getrennt von RQ.
|
||||||
|
- **PROCESS_ISOLATION:** Executor-Prozess getrennt von RQ.
|
||||||
|
- **CREDENTIAL_BLAST_RADIUS:** Ein Kompromiss des Executors = SAVE+DELETE beide offen.
|
||||||
|
- **DB_ACCESS:** Executor braucht c5a.db (Schreibzugriff).
|
||||||
|
- **QUEUE_REQUIREMENT:** Nein (CLI/Inbox möglich).
|
||||||
|
- **OPERATIONAL_COMPLEXITY:** Mittel (ein Container mehr).
|
||||||
|
- **FAIL_CLOSED:** Ja.
|
||||||
|
- **RECOVERY:** Einfach (ein Container).
|
||||||
|
- **AUDITABILITY:** Mittel.
|
||||||
|
- **CURRENT_STACK_COMPATIBILITY:** Hoch (Coolify/Compose vorhanden).
|
||||||
|
- **IMPLEMENTATION_EFFORT:** Mittel.
|
||||||
|
- **Fazit:** Verbessert RQ-Isolation, aber **löst SAVE/DELETE-Trennung NICHT** — nur LOGICAL_ONLY→Container-Ebene, nicht Scope-Ebene.
|
||||||
|
|
||||||
|
### OPTION B — Zwei getrennte Container: c5-save-executor + c5-delete-executor
|
||||||
|
- **SECURITY_BOUNDARY:** RQ↔SAVE↔DELETE je getrennt.
|
||||||
|
- **RQ_SECRET_VISIBILITY:** RQ kann beide nicht lesen.
|
||||||
|
- **SAVE_DELETE_ISOLATION:** **JA** — SAVE- und DELETE-Credential in getrennten Containern.
|
||||||
|
- **FILESYSTEM_ISOLATION:** SAVE- und DELETE-FS getrennt.
|
||||||
|
- **ENV_ISOLATION:** SAVE- und DELETE-ENV getrennt.
|
||||||
|
- **NETWORK_ISOLATION:** SAVE- und DELETE-Netzwerk getrennt.
|
||||||
|
- **PROCESS_ISOLATION:** SAVE- und DELETE-Prozess getrennt.
|
||||||
|
- **CREDENTIAL_BLAST_RADIUS:** Minimal — SAVE-Kompromiss berührt DELETE nicht und umgekehrt.
|
||||||
|
- **DB_ACCESS:** Beide brauchen c5a.db (Schreibzugriff) — **Konflikt mit DB-Isolation (Phase 7)**.
|
||||||
|
- **QUEUE_REQUIREMENT:** Nein (CLI/Inbox möglich).
|
||||||
|
- **OPERATIONAL_COMPLEXITY:** Höher (zwei Container mehr).
|
||||||
|
- **FAIL_CLOSED:** Ja.
|
||||||
|
- **RECOVERY:** Mittel (zwei Container).
|
||||||
|
- **AUDITABILITY:** Hoch.
|
||||||
|
- **CURRENT_STACK_COMPATIBILITY:** Hoch (Coolify/Compose vorhanden).
|
||||||
|
- **IMPLEMENTATION_EFFORT:** Höher.
|
||||||
|
- **Fazit:** **Erfüllt die Zielarchitektur** (SAVE≠DELETE technisch getrennt). Bevorzugt, sofern DB-Isolation lösbar.
|
||||||
|
|
||||||
|
### OPTION C — Separate Unix-Prozesse im selben Container, unterschiedliche Secret-Files/Usern
|
||||||
|
- **SECURITY_BOUNDARY:** Nur Prozess-Ebene; **gleicher Container/FS/ENV**.
|
||||||
|
- **RQ_SECRET_VISIBILITY:** RQ (User hermes) könnte Secret-Files lesen, wenn gleiche Rechte.
|
||||||
|
- **SAVE_DELETE_ISOLATION:** Nur wenn getrennte Unix-User + Dateirechte; **fragil**.
|
||||||
|
- **FILESYSTEM_ISOLATION:** Nur via Dateirechte (kein echtes FS-Isolation).
|
||||||
|
- **ENV_ISOLATION:** **NEIN** — gleiche ENV im Container.
|
||||||
|
- **NETWORK_ISOLATION:** **NEIN** — gleiche Netzwerk-Namespace.
|
||||||
|
- **PROCESS_ISOLATION:** Teilweise (getrennte Prozesse, gleicher Kernel/Container).
|
||||||
|
- **CREDENTIAL_BLAST_RADIUS:** Mittel — Container-Kompromiss = beide offen.
|
||||||
|
- **DB_ACCESS:** Beide c5a.db.
|
||||||
|
- **QUEUE_REQUIREMENT:** Nein.
|
||||||
|
- **OPERATIONAL_COMPLEXITY:** Niedrig.
|
||||||
|
- **FAIL_CLOSED:** Ja.
|
||||||
|
- **RECOVERY:** Einfach.
|
||||||
|
- **AUDITABILITY:** Niedrig.
|
||||||
|
- **CURRENT_STACK_COMPATIBILITY:** Hoch (kein neuer Container).
|
||||||
|
- **IMPLEMENTATION_EFFORT:** Niedrig.
|
||||||
|
- **Fazit:** **NICHT ausreichend** — ENV-Isolation fehlt, RQ (User hermes) könnte Secrets lesen. Verletzt RQ_SECRET_VISIBILITY=NO.
|
||||||
|
|
||||||
|
### OPTION D — Sidecar-/Broker-Modell
|
||||||
|
- **SECURITY_BOUNDARY:** Broker vermittelt; Executoren getrennt.
|
||||||
|
- **RQ_SECRET_VISIBILITY:** RQ sieht Secrets nicht (nur Broker).
|
||||||
|
- **SAVE_DELETE_ISOLATION:** Ja, wenn Broker getrennte Executoren ansteuert.
|
||||||
|
- **FILESYSTEM_ISOLATION:** Ja (getrennte Container).
|
||||||
|
- **ENV_ISOLATION:** Ja.
|
||||||
|
- **NETWORK_ISOLATION:** Ja.
|
||||||
|
- **PROCESS_ISOLATION:** Ja.
|
||||||
|
- **CREDENTIAL_BLAST_RADIUS:** Minimal.
|
||||||
|
- **DB_ACCESS:** Broker + Executoren.
|
||||||
|
- **QUEUE_REQUIREMENT:** **Ja** (Broker = Queue) — **neue Infrastruktur, NICHT vorhanden**.
|
||||||
|
- **OPERATIONAL_COMPLEXITY:** Hoch.
|
||||||
|
- **FAIL_CLOSED:** Ja.
|
||||||
|
- **RECOVERY:** Komplex.
|
||||||
|
- **AUDITABILITY:** Hoch.
|
||||||
|
- **CURRENT_STACK_COMPATIBILITY:** **Niedrig** — kein Broker/Queue im Stack.
|
||||||
|
- **IMPLEMENTATION_EFFORT:** Hoch.
|
||||||
|
- **Fazit:** **Abgelehnt** — erfordert neue Infrastruktur (Broker/Queue), die nicht vorhanden ist. Verletzt "KEINE neue Infrastruktur deployen".
|
||||||
|
|
||||||
|
### OPTION E — Queue-basierte Executor-Trennung (falls vorhandene Infrastruktur geeignet)
|
||||||
|
- **SECURITY_BOUNDARY:** Queue entkoppelt RQ von Executoren.
|
||||||
|
- **RQ_SECRET_VISIBILITY:** RQ sieht Secrets nicht.
|
||||||
|
- **SAVE_DELETE_ISOLATION:** Ja, wenn getrennte Queues/Executoren.
|
||||||
|
- **FILESYSTEM_ISOLATION:** Ja.
|
||||||
|
- **ENV_ISOLATION:** Ja.
|
||||||
|
- **NETWORK_ISOLATION:** Ja.
|
||||||
|
- **PROCESS_ISOLATION:** Ja.
|
||||||
|
- **CREDENTIAL_BLAST_RADIUS:** Minimal.
|
||||||
|
- **DB_ACCESS:** Executoren.
|
||||||
|
- **QUEUE_REQUIREMENT:** **Ja** — **keine Queue im Stack vorhanden** (verifiziert: kein RabbitMQ, kein Broker).
|
||||||
|
- **OPERATIONAL_COMPLEXITY:** Hoch.
|
||||||
|
- **FAIL_CLOSED:** Ja.
|
||||||
|
- **RECOVERY:** Komplex.
|
||||||
|
- **AUDITABILITY:** Hoch.
|
||||||
|
- **CURRENT_STACK_COMPATIBILITY:** **Niedrig** — keine vorhandene Queue.
|
||||||
|
- **IMPLEMENTATION_EFFORT:** Hoch.
|
||||||
|
- **Fazit:** **Abgelehnt** — keine vorhandene Queue-Infrastruktur; würde neue Infrastruktur erfordern.
|
||||||
|
|
||||||
|
### OPTION F — Andere MINIMAL sichere Variante
|
||||||
|
- **Kandidat F1: RQ credential-frei + SAVE/DELETE als getrennte Container (B) mit getrennter DB-View.**
|
||||||
|
Kombiniert B (Container-Trennung) mit minimaler DB-Boundary (Phase 7): SAVE-Executor
|
||||||
|
bekommt nur Commit/ObjectChange-Read + State-Write auf eigene Tabellen; DELETE-Executor
|
||||||
|
nur Approval/Ledger. **Bevorzugt**, da es die Zielarchitektur mit vorhandener Infrastruktur erfüllt.
|
||||||
|
- **Kandidat F2: RQ credential-frei + SAVE/DELETE als getrennte Container (B) mit geteilter c5a.db.**
|
||||||
|
Falls DB-Trennung nicht sauber möglich: geteilte c5a.db, aber SAVE-Executor erhält
|
||||||
|
KEIN DELETE-Credential und umgekehrt. **Akzeptabel**, aber DB-Boundary schwächer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PREFERRED ARCHITECTURE (Phase 4)
|
||||||
|
|
||||||
|
**Gewählt: OPTION B (zwei getrennte Container) mit OPTION F1 (minimale DB-Boundary).**
|
||||||
|
|
||||||
|
```
|
||||||
|
RQ/Hermes (Container 06a4a73573d0) — credential-frei
|
||||||
|
│ credential-freier Auftrag (CLI/Inbox, kein Secret)
|
||||||
|
▼
|
||||||
|
+---------------------------+ +-----------------------------+
|
||||||
|
| c5-save-executor (Cont.) | | c5-delete-executor (Cont.) |
|
||||||
|
| SAVE credential | | DELETE credential |
|
||||||
|
| NO DELETE | | NO SAVE |
|
||||||
|
| Tolaria SAVE + READ | | Tolaria DELETE |
|
||||||
|
| c5a.db: Commit/Object-Read| | c5a.db: Approval/Ledger |
|
||||||
|
+---------------------------+ +-----------------------------+
|
||||||
|
│ SAVE (Bearer save_token) │ DELETE (Bearer delete_token)
|
||||||
|
▼ ▼
|
||||||
|
Tolaria Server (187.124.31.123:5173) — erwartet SAVE+DELETE-Verifier
|
||||||
|
```
|
||||||
|
|
||||||
|
**Begründung (Priorität):**
|
||||||
|
1. **Credential-Isolation:** SAVE- und DELETE-Credential in getrennten Containern → RQ kann keins lesen, SAVE-Executor kann DELETE nicht, DELETE-Executor kann SAVE nicht.
|
||||||
|
2. **Fail-Closed:** Beide Executoren erben `_require_token` (AUTH.3A) — fehlendes/leeres Token → lokaler Abbruch.
|
||||||
|
3. **DELETE-Blast-Radius minimieren:** DELETE-Executor isoliert, kein SAVE-Credential, Human Gate bleibt.
|
||||||
|
4. **Auditierbarkeit:** Getrennte Container → getrennte Logs/Netzwerk.
|
||||||
|
5. **Wiederherstellbarkeit:** Getrennte Container → getrennter Rollback.
|
||||||
|
6. **Geringe Komplexität:** Keine neue Infrastruktur (Coolify/Compose vorhanden), kein Broker/Queue.
|
||||||
|
|
||||||
|
**Gegen reale Infrastruktur geprüft:**
|
||||||
|
- Coolify/Compose vorhanden → Container-Erstellung möglich (in AUTH.4, nicht jetzt).
|
||||||
|
- Kein Broker/Queue → CLI/Inbox-Channel (Phase 5), keine neue Infrastruktur.
|
||||||
|
- c5a.db SQLite → DB-Boundary via getrennte Tabellen/Dateien (Phase 7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. RQ → EXECUTOR COMMAND CHANNEL (Phase 5)
|
||||||
|
|
||||||
|
> ⚠️ NUR BEWERTUNG. KEINE neue Infrastruktur deployen.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
| Option | AUTH | AUTHZ | REPLAY_PROT | IDEMPOTENCY | JOB_ID | MISSION_ID | ACTION_HASH | AUDIT | FAIL_CLOSED | RQ_CAN_FORGE | RQ_CAN_ESCALATE | EXEC_CAN_VALIDATE |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **CLI (bestehend)** | OS-User | CLI-Arg | Nein (manuell) | Ja (Commit-SHA) | Commit-SHA | Nein | Nein | Shell-History | Ja | Ja (RQ ruft auf) | Nein (kein Secret) | Ja (Gates) |
|
||||||
|
| **Filesystem Inbox** | FS-Rechte | Datei | Teilweise | Ja | Dateiname | Nein | Nein | Datei-Meta | Ja | Ja | Nein | Ja |
|
||||||
|
| **SQLite Job Queue** | DB-Rechte | Job-Record | Teilweise | Ja | Job-ID | Ja | Ja | DB-Ledger | Ja | Ja | Nein | Ja |
|
||||||
|
| **RabbitMQ** | — | — | — | — | — | — | — | — | — | — | — | — | (nicht vorhanden) |
|
||||||
|
| **HTTP Internal API** | Token | Scope | Nein | Ja | Job-ID | Ja | Ja | Server-Log | Ja | Ja | Nein | Ja |
|
||||||
|
| **Unix Socket** | FS-Rechte | Peer | Nein | Ja | Job-ID | Ja | Ja | Socket-Log | Ja | Ja | Nein | Ja |
|
||||||
|
| **C5 State Machine** | DB-Rechte | State | Teilweise | Ja | Commit-SHA | Ja | Ja | DB-Ledger | Ja | Ja | Nein | Ja |
|
||||||
|
|
||||||
|
**Bewertung:**
|
||||||
|
- **CLI (bestehend)** ist der **einfachste, vorhandene** Kanal. RQ ruft `c5-save-executor`/`c5-delete-executor` als CLI auf. **RQ_CAN_FORGE_JOB = Ja** (RQ kann jeden Auftrag formulieren), aber **RQ_CAN_ESCALATE_SCOPE = Nein** (RQ hat kein Secret, kann nur beauftragen, nicht selbst schreiben). Der Executor validiert den Auftrag selbst (Gates).
|
||||||
|
- **SQLite Job Queue / C5 State Machine** sind die **robustesten** (JOB_ID, MISSION_ID, ACTION_HASH, DB-Ledger, Replay-Schutz). Nutzt vorhandene c5a.db — **keine neue Infrastruktur**.
|
||||||
|
- **RabbitMQ/HTTP/Socket** erfordern neue Infrastruktur oder neue Endpoints → **abgelehnt** (Scope-Creep).
|
||||||
|
|
||||||
|
**Empfehlung:** **CLI (bestehend) als primärer Kanal** für SAVE; **C5 State Machine (c5a.db) als primärer Kanal** für DELETE (Approval persistiert, Nonce, Single-Use, Replay-Schutz). Beide nutzen vorhandene Infrastruktur.
|
||||||
|
|
||||||
|
### DELETE-spezifisch (RQ darf NICHT durch freien Auftrag einen Delete autorisieren)
|
||||||
|
Der DELETE-Executor muss selbst prüfen (im Code verifiziert, rq_c5_delete.py):
|
||||||
|
- **erlaubte Operation:** ObjectChange.operation == DELETE (Gate 1)
|
||||||
|
- **Objekt:** ObjectChange existiert, genau einer (Gate 1)
|
||||||
|
- **Commit:** Commit-Status == ST_DELETE_APPROVED (Gate 2)
|
||||||
|
- **Approval:** existiert, APPROVED, passt exakt Commit/Objekt/Pfad (Gate 3)
|
||||||
|
- **Nonce:** Approval single-use (→ USED nach DELETE)
|
||||||
|
- **Single Use:** Approval→USED, kein Replay
|
||||||
|
- **State:** Commit→ST_UPDATING_SEARCH nach DELETE
|
||||||
|
- **Scope:** kein freier Pfad-Parameter (nur aus ObjectChange)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. SECRET VISIBILITY PROOF (Phase 6)
|
||||||
|
|
||||||
|
> ⚠️ NUR BEWEISFÜHRUNG. KEINE Secrets erzeugen/lesen.
|
||||||
|
|
||||||
|
### Zielarchitektur (OPTION B): Kann RQ SAVE/DELETE lesen?
|
||||||
|
| Prüfpunkt | RQ→SAVE | RQ→DELETE | SAVE-Exec→DELETE | DELETE-Exec→SAVE | Rain→beide |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **ENV** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **/proc** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **mounted files** | Getrennte Volumes → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **shared volumes** | Keine geteilten Secret-Volumes → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **Docker inspect** | Nur Host-Admin (nicht RQ) → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **Compose** | Secret via .env/Coolify, nicht Plain ENV → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **Logs** | Kein Secret-Logging (AUTH.3A) → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **crash dumps** | Kein Secret in Dumps → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **temp files** | Kein Secret in Temp → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **DB** | c5a.db enthält keine Secrets → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **IPC** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
| **shell history** | RQ ruft CLI ohne Secret-Arg → NEIN | NEIN | NEIN | NEIN | NEIN |
|
||||||
|
|
||||||
|
**Ergebnis:** Mit OPTION B ist die Isolation **erreichbar** mit der aktuellen Infrastruktur
|
||||||
|
(Coolify/Compose, getrennte Container, .env/Coolify-Secrets). **KEIN HARD STOP.**
|
||||||
|
|
||||||
|
> ⚠️ **Einschränkung (ehrlich):** Die Isolation ist nur **technisch erreichbar**, wenn
|
||||||
|
> die Secrets in getrennten Containern injiziert werden (nicht in den RQ-Container).
|
||||||
|
> Solange SAVE/DELETE im RQ-Container lägen, wäre RQ_SECRET_VISIBILITY=NO NICHT garantiert.
|
||||||
|
> Das ist genau der AUTH.3B-HARD-STOP, den OPTION B auflöst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. FILESYSTEM / DB SEPARATION (Phase 7)
|
||||||
|
|
||||||
|
> ⚠️ NUR BEWERTUNG. KEINE DB erzeugen/verändern.
|
||||||
|
|
||||||
|
### Welche DBs brauchen die Executoren?
|
||||||
|
| DB | SAVE-Executor | DELETE-Executor | RQ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **c5a.db (C5 State)** | Schreibzugriff (Commit/ObjectChange-State) | Schreibzugriff (Approval/Ledger) | Schreibzugriff |
|
||||||
|
| **missions.db** | NEIN | NEIN | Ja (RQ) |
|
||||||
|
| **safety.db** | NEIN | NEIN | Ja (RQ) |
|
||||||
|
| **heartbeat.db** | NEIN | NEIN | Ja (RQ) |
|
||||||
|
| **delete approvals** | NEIN (liest nicht) | **Ja** (lesen + USED) | Ja |
|
||||||
|
| **attempt ledger** | NEIN | **Ja** (schreiben) | Ja |
|
||||||
|
|
||||||
|
### Muss SAVE-Executor Schreibzugriff auf dieselbe DB wie RQ haben?
|
||||||
|
- **Nein, nicht auf missions/safety/heartbeat.** SAVE-Executor braucht nur c5a.db (Commit/ObjectChange).
|
||||||
|
- **c5a.db geteilt mit RQ:** Ja, aber auf minimale Tabellen begrenzbar.
|
||||||
|
|
||||||
|
### Muss DELETE-Executor Schreibzugriff haben?
|
||||||
|
- **Ja, auf c5a.db** (Approval→USED, Ledger, Commit-State). Nicht auf missions/safety/heartbeat.
|
||||||
|
|
||||||
|
### Kann Zugriff auf minimale Tabellen/DB-Dateien begrenzt werden?
|
||||||
|
- **Ja, via getrennte SQLite-Dateien** (OPTION F1): SAVE-Executor → `c5a_save.db` (Commit/ObjectChange-Read + State-Write); DELETE-Executor → `c5a_delete.db` (Approval/Ledger). Oder via SQLite-View/Readonly-Connections.
|
||||||
|
- **SQLite-Locking:** Getrennte Dateien vermeiden Lock-Konflikte zwischen SAVE- und DELETE-Executor. Geteilte Datei → WAL-Modus + kurze Transaktionen.
|
||||||
|
|
||||||
|
**Empfehlung:** **Getrennte SQLite-Dateien** für SAVE- und DELETE-Executor (minimale DB-Boundary), RQ behält c5a.db als Orchestrierungs-State. Keine neue DB-Engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. NETWORK POLICY DESIGN (Phase 8)
|
||||||
|
|
||||||
|
> ⚠️ NUR DESIGN. KEINE Firewall-/Docker-Network-Änderung.
|
||||||
|
|
||||||
|
### SAVE-Executor (nur notwendige Ziele)
|
||||||
|
| Ziel | Zugriff |
|
||||||
|
|---|---|
|
||||||
|
| Tolaria (187.124.31.123:5173) | **JA** (SAVE + READ) |
|
||||||
|
| Forgejo (10.0.4.1:3000) | **JA** (Source-/Provenance-Read) |
|
||||||
|
| Search | NEIN (kein Rebuild-Credential) |
|
||||||
|
| Internet | NEIN |
|
||||||
|
| Docker | NEIN |
|
||||||
|
| Host SSH | NEIN |
|
||||||
|
| Telegram | NEIN |
|
||||||
|
|
||||||
|
### DELETE-Executor (nur notwendige Ziele)
|
||||||
|
| Ziel | Zugriff |
|
||||||
|
|---|---|
|
||||||
|
| Tolaria (187.124.31.123:5173) | **JA** (DELETE) |
|
||||||
|
| Forgejo (10.0.4.1:3000) | **JA** (Object/Commit-Read) |
|
||||||
|
| Search | NEIN |
|
||||||
|
| Internet | NEIN |
|
||||||
|
| Docker | NEIN |
|
||||||
|
| Host SSH | NEIN |
|
||||||
|
| Telegram | NEIN |
|
||||||
|
|
||||||
|
**Ziel:** Beide Executoren erreichen nur Tolaria + Forgejo (interne State-Quellen). Kein Internet, kein Docker, kein SSH, kein Telegram.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. FAILURE MODEL (Phase 9)
|
||||||
|
|
||||||
|
> ⚠️ NUR ANALYSE. FAIL CLOSED.
|
||||||
|
|
||||||
|
| Szenario | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| **SAVE-Executor down** | RQ kann nicht propagieren → Commit bleibt in READY/PROPAGATING. Kein Datenverlust. FAIL CLOSED. |
|
||||||
|
| **DELETE-Executor down** | DELETE nicht ausführbar → Commit bleibt ST_DELETE_APPROVED. **Nichts wird gelöscht.** FAIL CLOSED. |
|
||||||
|
| **RQ down** | Executoren warten auf Auftrag. Kein automatischer Write. FAIL CLOSED. |
|
||||||
|
| **Tolaria down** | Executor → TolariaUnavailableError → Retry/DEAD (C5C-Modell). FAIL CLOSED. |
|
||||||
|
| **DB locked** | SQLite-Lock → Retry/Fehler. Kein Write ohne State. FAIL CLOSED. |
|
||||||
|
| **Queue unavailable** | CLI/Inbox → kein Queue-Abhängigkeit. FAIL CLOSED. |
|
||||||
|
| **Credential missing** | `_require_token` → lokaler Abbruch (RC_AUTH_FAILURE). FAIL CLOSED. |
|
||||||
|
| **Credential revoked** | Server → 401. Executor meldet Fehler. FAIL CLOSED. |
|
||||||
|
| **malformed Job** | Executor-Gates → DeleteApprovalError/ExecutionError. FAIL CLOSED. |
|
||||||
|
| **duplicate Job** | Idempotenz (Commit-SHA) → DELETE_ALREADY_AT_TARGET. Kein zweiter Write. |
|
||||||
|
| **replayed Job** | Approval single-use (→ USED) → Replay denied. FAIL CLOSED. |
|
||||||
|
| **Job stale** | Commit-Status ≠ ST_DELETE_APPROVED → denied. FAIL CLOSED. |
|
||||||
|
| **Approval wrong** | Gate 3 (exakter Match) → denied. FAIL CLOSED. |
|
||||||
|
| **Object wrong** | Gate 1 (genau ein DELETE-ObjectChange) → denied. FAIL CLOSED. |
|
||||||
|
| **Commit wrong** | Gate 2 (Status) → denied. FAIL CLOSED. |
|
||||||
|
| **Executor restart mid-operation** | Replay (idempotent) → Zustand aus c5a.db. Keine Sicherheitsinvariante verloren. |
|
||||||
|
|
||||||
|
**Erwartung:** **FAIL CLOSED.** DELETE insbesondere: **Unklarheit = NICHT löschen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. DELETE SPECIAL SECURITY (Phase 10)
|
||||||
|
|
||||||
|
> ⚠️ NUR DESIGN. HUMAN_APPROVAL_AUTHENTICITY_GAP bleibt OPEN (AUTH.3D).
|
||||||
|
|
||||||
|
**Zusätzliche Anforderungen für DELETE-Executor:**
|
||||||
|
- **Eigener Container bevorzugt:** Ja (OPTION B, c5-delete-executor).
|
||||||
|
- **Eigenes Credential:** Ja (TOLARIA_DELETE_TOKEN, getrennt von SAVE).
|
||||||
|
- **Kein SAVE:** Ja (kein SAVE-Credential, kein SAVE-Endpoint-Zugriff).
|
||||||
|
- **Human Approval zwingend:** Ja (Gate 3, persistierte Approval).
|
||||||
|
- **Approval single-use:** Ja (→ USED nach DELETE).
|
||||||
|
- **Object-bound:** Ja (Gate 1, genau ein DELETE-ObjectChange).
|
||||||
|
- **Commit-bound:** Ja (Gate 2, ST_DELETE_APPROVED).
|
||||||
|
- **Nonce-bound:** Ja (Approval-ID als Nonce).
|
||||||
|
- **Replay-safe:** Ja (single-use + Idempotenz).
|
||||||
|
- **Vollständiges Audit Ledger:** Ja (c5a.db delete_approvals + attempt ledger).
|
||||||
|
- **Kein LLM kann Approval selbst erzeugen:** **NICHT garantiert** — HUMAN_APPROVAL_AUTHENTICITY_GAP=*** (Provenance nicht kryptographisch an Christian gebunden, `--approved-by` frei wählbar).
|
||||||
|
|
||||||
|
**ABER:** HUMAN_APPROVAL_AUTHENTICITY_GAP bleibt OPEN. AUTH.3C modelliert ihn nur als Blocker, repariert ihn NICHT (AUTH.3D).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. MIGRATION DESIGN (Phase 11)
|
||||||
|
|
||||||
|
> ⚠️ NUR PLAN. KEINE AUSFÜHRUNG.
|
||||||
|
|
||||||
|
**HEUTE:** RQ-Container ├ C5C Writer └ DeleteExecutor
|
||||||
|
**ZIEL:** RQ-Container (credential-frei) + c5-save-executor + c5-delete-executor
|
||||||
|
|
||||||
|
### Atomare Schritte
|
||||||
|
| Schritt | GOAL | FILES | CONTAINERS | MOUNTS | NETWORKS | DB | CREDENTIALS | TEST | ROLLBACK | HUMAN_GATE | PROD_IMPACT |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **S1** | Executor-Code extrahieren (SAVE/DELETE als eigenständige CLI) | rq_c5c.py, rq_c5_delete.py → executor-Module | — | — | — | — | — | Unit-Tests | Git-Revert | Nein | Keine |
|
||||||
|
| **S2** | c5-save-executor-Image bauen | Dockerfile | c5-save-executor | /app/vault? (nein) | c5-net | c5a_save.db | SAVE-Token (synthetisch) | Isolierte Tests | Image entfernen | Nein | Keine |
|
||||||
|
| **S3** | c5-delete-executor-Image bauen | Dockerfile | c5-delete-executor | — | c5-net | c5a_delete.db | DELETE-Token (synthetisch) | Isolierte Tests | Image entfernen | Nein | Keine |
|
||||||
|
| **S4** | RQ credential-frei machen (ENV-Tokens entfernen) | — | RQ | — | — | — | — | ENV-Check | ENV wiederherstellen | Nein | Keine (RQ hat keine Tokens) |
|
||||||
|
| **S5** | Command-Channel (CLI/Inbox) verdrahten | rq_c5_cli.py | — | — | — | — | — | Integration | Git-Revert | Nein | Keine |
|
||||||
|
| **S6** | Tolaria-Server-Auth aktivieren (AUTH.2) | — | tolaria | — | — | — | SAVE+DELETE-Verifier | T1-T26 | Rollback (WRITE_DEGRADED) | **Ja** | Write-Downtime-Fenster |
|
||||||
|
| **S7** | SAVE-Aktivierung (produktiv) | — | c5-save-executor | — | — | — | SAVE-Token (echt) | T1-T26 | Executor stoppen | **Ja** | SAVE aktiv |
|
||||||
|
| **S8** | DELETE-Aktivierung (produktiv) | — | c5-delete-executor | — | — | — | DELETE-Token (echt) | T1-T26 | Executor stoppen | **Ja** | DELETE aktiv |
|
||||||
|
|
||||||
|
**Rollback:** Jeder Schritt einzeln revertierbar (Git-Revert, Image entfernen, ENV wiederherstellen, Executor stoppen). S6-Rollback = WRITE_DEGRADED_MODE (READ available, WRITE DENIED) — öffnet keine unauth Writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. ISOLATED PROTOTYPE (Phase 12)
|
||||||
|
|
||||||
|
> ⚠️ NUR falls vollständig ohne Produktion möglich. Sonst DESIGN_ONLY.
|
||||||
|
|
||||||
|
**Klassifikation: DESIGN_ONLY** — Ein echter Isolation-PoC (getrennte Container) ist
|
||||||
|
**nicht ohne Produktionsänderung** möglich, da Container-Erstellung/Netzwerk-Änderung
|
||||||
|
produktive Infrastruktur berührt (verboten in AUTH.3C). Daher wird der PoC **nicht
|
||||||
|
improvisiert**; die Boundary wird stattdessen durch **isolierte Unit/Integration-Tests**
|
||||||
|
auf Prozess-Ebene validiert (synthetische Secrets, Fake-Tolaria, Temp-DB).
|
||||||
|
|
||||||
|
**Isolierte Tests (T1–T15) — als Testplan definiert, in AUTH.4 ausführbar:**
|
||||||
|
| Test | Nachweis |
|
||||||
|
|---|---|
|
||||||
|
| T1 | RQ ohne Secret → kein Write möglich |
|
||||||
|
| T2 | SAVE-Executor sieht SAVE |
|
||||||
|
| T3 | SAVE-Executor sieht DELETE nicht |
|
||||||
|
| T4 | DELETE-Executor sieht DELETE |
|
||||||
|
| T5 | DELETE-Executor sieht SAVE nicht |
|
||||||
|
| T6 | SAVE kann Delete nicht |
|
||||||
|
| T7 | DELETE kann Save nicht |
|
||||||
|
| T8 | fehlendes Credential → fail-closed |
|
||||||
|
| T9 | malformed Job → fail-closed |
|
||||||
|
| T10 | duplicate/replay sicher |
|
||||||
|
| T11 | Delete ohne Approval → denied |
|
||||||
|
| T12 | Delete mit gefälschter Approval → denied (soweit heutiger Mechanismus erkennt) |
|
||||||
|
| T13 | Executor restart verliert keine Sicherheitsinvariante |
|
||||||
|
| T14 | keine Secrets in Logs |
|
||||||
|
| T15 | keine Secrets in State DB |
|
||||||
|
|
||||||
|
**ISOLATED_POC_STATUS: DESIGN_ONLY** (kein echter Container-PoC ohne Produktionsänderung möglich).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. AUTH.3D DEPENDENCY (Phase 13)
|
||||||
|
|
||||||
|
> ⚠️ NICHT vorwegnehmen — verifizieren.
|
||||||
|
|
||||||
|
**Kann Runtime-Isolation abgeschlossen werden, während HUMAN_APPROVAL_AUTHENTICITY_GAP offen bleibt?**
|
||||||
|
|
||||||
|
- **SAVE_RUNTIME_ISOLATION:** **READY** — unabhängig von Approval-Gap. SAVE-Executor braucht keine Human-Approval; die Container-Trennung (OPTION B) ist vollständig ohne Gap-Schließung umsetzbar.
|
||||||
|
- **DELETE_RUNTIME_ISOLATION:** **technisch implementierbar** — die Container-Trennung (OPTION B) ist unabhängig vom Gap umsetzbar. Der DELETE-Executor kann isoliert deployed werden.
|
||||||
|
- **DELETE_OPERATION_ACTIVATION:** **BLOCKED bis AUTH.3D** — solange HUMAN_APPROVAL_AUTHENTICITY_GAP=*** offen ist, darf DELETE produktiv nicht aktiviert werden (Provenance nicht kryptographisch an Christian gebunden).
|
||||||
|
|
||||||
|
**AUTH3D_REQUIRED: JA** (für DELETE_OPERATION_ACTIVATION).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. AUTH.4 REASSESSMENT (Phase 14)
|
||||||
|
|
||||||
|
> ⚠️ NUR BEWERTUNG. KEINE Aktivierung.
|
||||||
|
|
||||||
|
| Punkt | Status | Blocker |
|
||||||
|
|---|---|---|
|
||||||
|
| **SERVER_AUTH_DEPLOYMENT** | **CONDITIONALLY_READY** | AUTH.2-Code fertig; Deployment in AUTH.4 mit Human-Gate |
|
||||||
|
| **SAVE_EXECUTOR_DEPLOYMENT** | **CONDITIONALLY_READY** | Container-Erstellung in AUTH.4; Code extrahieren (S1) |
|
||||||
|
| **SAVE_ACTIVATION** | **CONDITIONALLY_READY** | Nach SAVE-Executor-Deployment + Server-Auth |
|
||||||
|
| **DELETE_EXECUTOR_DEPLOYMENT** | **CONDITIONALLY_READY** | Container-Erstellung in AUTH.4; Code extrahieren (S1) |
|
||||||
|
| **DELETE_CREDENTIAL_ACTIVATION** | **CONDITIONALLY_READY** | Nach DELETE-Executor-Deployment (Boundary gelöst) |
|
||||||
|
| **DELETE_OPERATION_ACTIVATION** | **BLOCKED** | HUMAN_APPROVAL_AUTHENTICITY_GAP=*** bis AUTH.3D |
|
||||||
|
| **HERMES_READ_ONLY_AUTONOMY** | **CONDITIONALLY_READY** | RQ credential-frei; READ ohne Credential |
|
||||||
|
| **HERMES_BOUNDED_AUTONOMY** | **BLOCKED** | DELETE-Operation blockiert bis AUTH.3D |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. FRESH CHECKER (Phase 15)
|
||||||
|
|
||||||
|
> ⚠️ Wird als unabhängiger Subagent ausgeführt (deleg_60a862ee).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. FINAL REPORT (Phase 16)
|
||||||
|
|
||||||
|
> ⚠️ Wird nach Fresh Checker ausgefüllt.
|
||||||
422
tolaria/tolaria-write-auth/AUTH3D_DESIGN.md
Normal file
422
tolaria/tolaria-write-auth/AUTH3D_DESIGN.md
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
# AUTH.3D — HUMAN DELETE APPROVAL AUTHENTICITY
|
||||||
|
|
||||||
|
**Status:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY (KEIN produktives Deployment)
|
||||||
|
**Datum:** 2026-08-27
|
||||||
|
**Mission Type:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY
|
||||||
|
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B + AUTH.3C = COMPLETE
|
||||||
|
|
||||||
|
> ⚠️ Dies ist ein **Design + Code + Test-Dokument**. Es wird KEIN produktives
|
||||||
|
> Deployment ausgeführt, kein echter Signing Key erzeugt, kein Christians Private
|
||||||
|
> Key erzeugt, kein echter Public Key deployed, keine ENV geändert, kein Container
|
||||||
|
> erstellt, kein Executor deployed, keine produktive SAVE/DELETE-Probe. Bei Zweifel:
|
||||||
|
> FAIL CLOSED + HARD STOP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CURRENT APPROVAL FLOW (Phase 1) — rekonstruiert (READ-ONLY)
|
||||||
|
|
||||||
|
### 1a. Vollständiger Flow
|
||||||
|
```
|
||||||
|
Delete Request → State Machine → Approval Creation → Approval Persistence
|
||||||
|
→ DeleteExecutor → Approval Validation → Tolaria DELETE → Ledger/Consumption
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Delete Request:** Ein Forgejo-Commit enthält einen DELETE-ObjectChange (`operation == OP_DELETE_REQUEST`).
|
||||||
|
2. **State Machine:** Commit wird in C5-State-Machine verarbeitet (C5AStore, c5a.db).
|
||||||
|
3. **Approval Creation:** `cmd_c5_delete_approve` (CLI, rq_c5_cli.py Z.355) → `store.create_delete_approval()`.
|
||||||
|
4. **Approval Persistence:** SQLite-Tabelle `delete_approvals` (c5a.db).
|
||||||
|
5. **DeleteExecutor:** `cmd_c5_delete_execute` (CLI, Z.424) → `DeleteExecutor.execute()`.
|
||||||
|
6. **Approval Validation:** `_validate_approval()` (rq_c5_delete.py Z.105) — Invarianten D/E/F/I.
|
||||||
|
7. **Tolaria DELETE:** `_do_delete()` → `client.delete()`.
|
||||||
|
8. **Ledger/Consumption:** `mark_delete_approval_used()` → `approval_status='USED'`, Commit → ST_UPDATING_SEARCH.
|
||||||
|
|
||||||
|
### 1b. Wer kann Approval heute erzeugen?
|
||||||
|
- **NUR der CLI-Befehl `approve-delete`** (`cmd_c5_delete_approve`). Kein Code erzeugt Approval automatisch (Invariante B/C).
|
||||||
|
- **Jeder, der die CLI im RQ-Container ausführen kann** (User `hermes`), kann eine Approval erzeugen.
|
||||||
|
- **`approved_by` ist ein frei wählbares CLI-Argument** (Default `human:christian`). **KEINE kryptographische Bindung an Christian.**
|
||||||
|
|
||||||
|
### 1c. Exakte Felder (DB-Tabelle `delete_approvals`)
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `approval_id` | TEXT (uuid4) | Eindeutige ID |
|
||||||
|
| `workflow_commit` | TEXT | Commit-Binding |
|
||||||
|
| `object_change_id` | INT | ObjectChange-Binding |
|
||||||
|
| `object_id` | TEXT | Objekt-Binding |
|
||||||
|
| `path` | TEXT | Pfad-Binding |
|
||||||
|
| `operation` | TEXT | 'DELETE' |
|
||||||
|
| `approval_status` | TEXT | APPROVED/USED/REVOKED |
|
||||||
|
| `approved_by` | TEXT | **frei wählbar** (Default human:christian) |
|
||||||
|
| `approval_nonce` | TEXT (uuid4) | Nonce (kryptographisch zufällig, aber NICHT signiert) |
|
||||||
|
| `approved_at` | INT (ms) | Zeitpunkt |
|
||||||
|
| `used_at` | INT (ms) | Single-Use-Zeitpunkt |
|
||||||
|
|
||||||
|
### 1d. Bestehende Bindings (im Code verifiziert)
|
||||||
|
- **Object-Binding:** `_validate_approval` Z.143 — `approval.object_id == oid` (Invariante E).
|
||||||
|
- **Commit-Binding:** Z.136 — `approval.workflow_commit == commit_sha` (Invariante F).
|
||||||
|
- **Path-Binding:** Z.150 — `approval.path == path` (Invariante I).
|
||||||
|
- **Operation-Binding:** `create_delete_approval` setzt `operation='DELETE'` fest.
|
||||||
|
- **Nonce:** `approval_nonce` = uuid4, aber **nicht signiert, nicht verifiziert**.
|
||||||
|
- **Single-Use:** `mark_delete_approval_used` → `approval_status='USED'`; `_validate_approval` prüft `approval_status == 'APPROVED'`.
|
||||||
|
- **Mission-Binding:** **FEHLT** — kein `mission_id`-Feld.
|
||||||
|
- **Provenance-Binding:** **FEHLT** — kein `expected_provenance_hash`-Feld.
|
||||||
|
- **Expiry:** **FEHLT** — kein `expires_at`-Feld.
|
||||||
|
- **Actor-/Identity-Information:** `approved_by` (frei wählbar).
|
||||||
|
- **Authentizitätsprüfung:** **KEINE** — es gibt keine kryptographische Prüfung, dass die Approval von Christian stammt.
|
||||||
|
|
||||||
|
### 1e. Kernbefund
|
||||||
|
**HUMAN_APPROVAL_AUTHENTICITY_GAP = OPEN.** Die Approval ist eine reine DB-Zeile mit
|
||||||
|
frei wählbarem `approved_by`. Jeder mit CLI-/DB-Zugriff (RQ/Hermes, User `hermes`)
|
||||||
|
kann eine syntaktisch perfekte Approval erzeugen. Es gibt **keine kryptographische
|
||||||
|
Bindung an Christian**. → DELETE_OPERATION_ACTIVATION bleibt BLOCKED.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. THREAT MODEL (Phase 2)
|
||||||
|
|
||||||
|
> ⚠️ NUR ANALYSE. FAIL CLOSED.
|
||||||
|
|
||||||
|
| Threat | CURRENT_PROTECTION | CURRENT_GAP | TARGET_CONTROL | FAIL_CLOSED_BEHAVIOR |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **T1 RQ erzeugt selbst Approval** | CLI-Zugriff (User hermes) | RQ kann `approve-delete` ausführen | RQ besitzt keinen Signing Key → kann keine gültige signierte Approval erzeugen | DELETE_DENIED |
|
||||||
|
| **T2 Hermes erzeugt selbst Approval** | Gleiche Boundary wie RQ | Hermes kann CLI ausführen | Hermes besitzt keinen Signing Key | DELETE_DENIED |
|
||||||
|
| **T3 Rain erzeugt Approval** | Separater Container | Rain hat keinen CLI-Zugriff auf c5a.db | Rain besitzt keinen Signing Key | DELETE_DENIED |
|
||||||
|
| **T4 SAVE Executor erzeugt Approval** | Separater Container (AUTH.3C) | Kein CLI-Zugriff auf c5a.db | SAVE Executor besitzt keinen Signing Key | DELETE_DENIED |
|
||||||
|
| **T5 DELETE Executor erzeugt Approval** | Separater Container (AUTH.3C) | Kein CLI-Zugriff auf c5a.db | DELETE Executor ist **verify-only**, besitzt keinen Signing Key | DELETE_DENIED |
|
||||||
|
| **T6 Agent verändert gespeicherte Approval** | DB-Zugriff | DB-Zeile kann geändert werden | Signatur über Payload → Änderung invalidiert Signatur | DELETE_DENIED |
|
||||||
|
| **T7 Agent verändert Object nach Approval** | Object-Binding (E) | Nur DB-Vergleich, nicht kryptographisch | Object im signierten Payload → Änderung invalidiert Signatur | DELETE_DENIED |
|
||||||
|
| **T8 Agent verändert Commit nach Approval** | Commit-Binding (F) | Nur DB-Vergleich | Commit im signierten Payload | DELETE_DENIED |
|
||||||
|
| **T9 Agent verändert Path nach Approval** | Path-Binding (I) | Nur DB-Vergleich | Path im signierten Payload | DELETE_DENIED |
|
||||||
|
| **T10 Approval wird kopiert** | approval_id uuid4 | Kopie ist gültig (keine Signatur) | Signatur + Single-Use → Kopie hat gleiche Signatur, aber Nonce/consumed | DELETE_DENIED (Replay) |
|
||||||
|
| **T11 Approval wird wiederverwendet** | Single-Use (USED) | Nur DB-Status | Signatur + consumed-check | DELETE_DENIED |
|
||||||
|
| **T12 Approval wird zwischen Missions übertragen** | Kein mission_id | Kein Mission-Binding | mission_id im signierten Payload | DELETE_DENIED |
|
||||||
|
| **T13 alte Approval nach State Change** | Commit-Status-Check (Gate 2) | Nur DB-Status | State-Check + Signatur | DELETE_DENIED |
|
||||||
|
| **T14 Nonce wird wiederverwendet** | uuid4 | Nonce nicht signiert/verifiziert | Nonce im signierten Payload + consumed-check | DELETE_DENIED |
|
||||||
|
| **T15 DB-Eintrag wird manipuliert** | — | DB-Zeile allein erzeugt gültige Approval | Signatur → DB-Forgery ohne Private Key unmöglich | DELETE_DENIED |
|
||||||
|
| **T16 Approval aus Backup wiederhergestellt** | — | Backup enthält gültige Approval | Signatur + consumed-check + expires_at | DELETE_DENIED |
|
||||||
|
| **T17 Approval nach Executor-Restart erneut verwendet** | Single-Use (USED) | Nur DB-Status | consumed-check + Signatur | DELETE_DENIED |
|
||||||
|
| **T18 Race zwischen Verify und Delete** | — | TOCTOU-Fenster | Atomare Reservation (RESERVED) | DELETE_DENIED |
|
||||||
|
| **T19 LLM erzeugt syntaktisch perfekte Fake-Approval** | — | Keine Signatur | Signatur → LLM ohne Private Key kann nicht signieren | DELETE_DENIED |
|
||||||
|
| **T20 kompromittierter RQ-Prozess versucht Approval-Bypass** | — | RQ kann CLI ausführen | RQ besitzt keinen Signing Key | DELETE_DENIED |
|
||||||
|
|
||||||
|
**Kern-Gap:** Alle Bindings (Object/Commit/Path) sind nur DB-Vergleiche, nicht
|
||||||
|
kryptographisch. `approved_by` ist frei wählbar. **Es fehlt eine kryptographische
|
||||||
|
Bindung an Christian.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. HUMAN IDENTITY ROOT (Phase 3) — Optionen
|
||||||
|
|
||||||
|
> ⚠️ NUR BEWERTUNG. KEINE Keys erzeugen.
|
||||||
|
|
||||||
|
| Option | HUMAN_AUTH | LLM_CAN_FORGE | RQ_CAN_FORGE | DELETE_EXEC_CAN_FORGE | PRIVATE_SECRET_EXPOSURE | REPLAY_RES | AUDIT | ROTATION | REVOCATION | BACKUP_RISK | OP_COMPLEXITY | STACK_COMPAT | OFFLINE_RECOVERY |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **A: HMAC (geteiltes Secret)** | Mittel | Nein (ohne Secret) | Nein (ohne Secret) | Nein (verify-only) | **Hoch** (Secret muss bei Verifier liegen) | Mittel | Mittel | Ja | Ja | Mittel | Niedrig | Hoch | Ja |
|
||||||
|
| **B: Asymmetrisch (Ed25519)** | **Hoch** | **Nein** | **Nein** | **Nein** (verify-only) | **Niedrig** (nur Public Key bei Verifier) | **Hoch** | **Hoch** | Ja | Ja | Niedrig | Mittel | **Hoch** (Python stdlib/cryptography) | Ja |
|
||||||
|
| **C: Forgejo-signiertes Artefakt** | Mittel | Nein | Nein | Nein | Mittel (Key bei Forgejo) | Mittel | Hoch | Ja | Ja | Mittel | Hoch | Hoch | Mittel |
|
||||||
|
| **D: Separater Approval-Service** | Hoch | Nein | Nein | Nein | Niedrig | Hoch | Hoch | Ja | Ja | Niedrig | **Hoch** (neue Infrastruktur) | **Niedrig** | Mittel |
|
||||||
|
| **E: Telegram + kryptogr. Bindung** | Mittel | Nein | Nein | Nein | Mittel | Mittel | Mittel | Ja | Ja | Mittel | Mittel | Hoch | Ja |
|
||||||
|
| **F: Andere minimal sichere** | — | — | — | — | — | — | — | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
**Bewertung:**
|
||||||
|
- **OPTION B (Ed25519 asymmetrisch)** ist die **stärkste und stack-kompatible** Lösung:
|
||||||
|
Christian besitzt den Private Key, der DELETE Executor kennt nur den Public Key.
|
||||||
|
**LLM/RQ/SAVE/DELETE-Executor können ohne Private Key nicht signieren.**
|
||||||
|
Python `cryptography`-Bibliothek (oder `pynacl`) unterstützt Ed25519 nativ.
|
||||||
|
- **OPTION A (HMAC)** ist schwächer: das geteilte Secret müsste beim Verifier liegen,
|
||||||
|
was die Exposure-Fläche vergrößert. Nicht bevorzugt.
|
||||||
|
- **OPTION C (Forgejo-signiert)** ist möglich, aber komplexer und bindet an Forgejo.
|
||||||
|
- **OPTION D (separater Service)** erfordert neue Infrastruktur → Scope-Creep.
|
||||||
|
- **OPTION E (Telegram)** allein ist KEIN kryptographischer Proof (siehe §7).
|
||||||
|
|
||||||
|
**Empfehlung: OPTION B (Ed25519 asymmetrisch).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PREFERRED SECURITY PROPERTY (Phase 4)
|
||||||
|
|
||||||
|
**Bevorzugte Eigenschaft (bestätigt):**
|
||||||
|
- **Christian:** PRIVATE KEY (Ed25519)
|
||||||
|
- **DELETE Executor:** PUBLIC KEY ONLY (verify-only)
|
||||||
|
- **RQ/Hermes:** NO PRIVATE KEY
|
||||||
|
- **SAVE Executor:** NO PRIVATE KEY
|
||||||
|
- **Rain:** NO PRIVATE KEY
|
||||||
|
- **Tolaria:** NO PRIVATE KEY
|
||||||
|
|
||||||
|
**Gegen reale Architektur bewertet:**
|
||||||
|
- Python `cryptography`/`pynacl` ist im Stack verfügbar (C5-Caller sind Python).
|
||||||
|
- Keine neue Infrastruktur nötig.
|
||||||
|
- Der DELETE Executor kann eine Approval **verifizieren**, aber **nicht erzeugen**.
|
||||||
|
- **Bestätigt als bevorzugte Lösung.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. APPROVAL PAYLOAD (Phase 5) — kanonisch
|
||||||
|
|
||||||
|
### Kanonischer Payload
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"approval_version": 1,
|
||||||
|
"approval_id": "<uuid4>",
|
||||||
|
"mission_id": "<mission-id>",
|
||||||
|
"delete_request_id": "<delete-request-id>",
|
||||||
|
"operation": "DELETE",
|
||||||
|
"object_id": "<object-id>",
|
||||||
|
"vault_path": "<vault-path>",
|
||||||
|
"expected_commit": "<commit-sha>",
|
||||||
|
"expected_provenance_hash": "<sha256>",
|
||||||
|
"nonce": "<uuid4>",
|
||||||
|
"issued_at": "<ISO-8601-UTC>",
|
||||||
|
"expires_at": "<ISO-8601-UTC>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Canonicalization
|
||||||
|
- **CANONICALIZATION:** Deterministische Serialisierung: Felder in fester Reihenfolge
|
||||||
|
(wie oben), keine Duplikate, keine Whitespace-Varianz, UTF-8, keine Unicode-Normalisierung
|
||||||
|
(exakte Bytes), Pfad-Normalisierung (kein `./`, kein `..`, keine doppelten Slashes).
|
||||||
|
- **ENCODING:** UTF-8, JSON ohne optionale Felder (nur die 12 Pflichtfelder).
|
||||||
|
- **HASH:** SHA-256 über den kanonisierten Payload-Bytes.
|
||||||
|
- **SIGNATURE_INPUT:** Die kanonisierten Payload-Bytes selbst (nicht der Hash) werden
|
||||||
|
signiert (Ed25519 signiert intern den Hash). Keine Signatur über frei formatierte Texte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. APPROVAL SIGNATURE (Phase 6) — Ed25519
|
||||||
|
|
||||||
|
- **SIGNATURE_ALGORITHM:** **Ed25519** (RFC 8032), via Python `cryptography` oder `pynacl`.
|
||||||
|
- **PRIVATE_KEY_OWNER:** Christian (einziger Besitzer).
|
||||||
|
- **PRIVATE_KEY_LOCATION:** Christians vertrauenswürdiges Gerät (NICHT im Stack).
|
||||||
|
- **PUBLIC_KEY_LOCATION:** DELETE Executor-Konfiguration (read-only, injiziert via Secret/File).
|
||||||
|
- **KEY_ID:** SHA-256-Hash des Public Keys (kurz, zur Key-Identifikation).
|
||||||
|
- **SIGNATURE_FORMAT:** Ed25519-Signatur (64 Bytes), Base64-URL-encoded.
|
||||||
|
- **VERIFICATION:** `verify(public_key, canonical_payload_bytes, signature)`.
|
||||||
|
- **ROTATION:** Neuer Key → neue KEY_ID; alte Approvals mit altem Key bleiben bis Expiry gültig (Multi-Key-Transition).
|
||||||
|
- **REVOCATION:** Key-Revocation-Liste (KEY_ID → revoked); verifier prüft.
|
||||||
|
- **MULTI_KEY_TRANSITION:** Verifier akzeptiert mehrere Public Keys (aktuelle + vorherige), markiert ältere als "transitioning".
|
||||||
|
|
||||||
|
**Private Key darf NICHT liegen in:** RQ ENV, Hermes ENV, Rain ENV, SAVE Executor,
|
||||||
|
DELETE Executor, Forgejo Repo, C5 DB, Tolaria, Logs, Reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. APPROVAL CREATION CHANNEL (Phase 7)
|
||||||
|
|
||||||
|
> ⚠️ NUR DESIGN. KEINE Implementierung in AUTH.3D (nur Code + Tests).
|
||||||
|
|
||||||
|
### Modelle
|
||||||
|
| Modell | UX | Sicherheit | Stack-Kompatibilität |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A: Lokales Signing Tool auf Christians Gerät** | Mittel (Christian signiert manuell) | **Hoch** (Private Key nie im Stack) | Hoch (Python-Skript) |
|
||||||
|
| **B: Separater Approval-Container/Service** | Niedrig | Hoch | **Niedrig** (neue Infrastruktur) |
|
||||||
|
| **C: Telegram Approval → separater Signer** | Hoch | Mittel (Signer muss getrennt sein) | Mittel |
|
||||||
|
| **D: Forgejo Workflow** | Mittel | Mittel | Hoch |
|
||||||
|
| **E: Anderer sicherer Kanal** | — | — | — |
|
||||||
|
|
||||||
|
**Empfehlung: OPTION A (lokales Signing Tool auf Christians Gerät).** Christian
|
||||||
|
erzeugt den kanonischen Payload (aus dem Delete-Request), signiert ihn mit seinem
|
||||||
|
Private Key, und übergibt die signierte Approval (Payload + Signatur + KEY_ID) an
|
||||||
|
den DELETE Executor (via CLI/Inbox). Der Executor verifiziert mit dem Public Key.
|
||||||
|
|
||||||
|
**WICHTIG (strikte Unterscheidung):**
|
||||||
|
- **HUMAN_INTENT:** Ein Telegram-Text "Ja"/"Approve"/"Delete" ist NUR Intent, KEIN kryptographischer Proof.
|
||||||
|
- **CRYPTOGRAPHIC_APPROVAL_ARTIFACT:** Die signierte Approval (Payload + Signatur) ist der einzige gültige Proof.
|
||||||
|
- RQ darf NICHT aus einem Telegram-Text den finalen Approval-Datensatz erzeugen können (ohne Christians Private Key).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. APPROVAL VERIFICATION (Phase 8)
|
||||||
|
|
||||||
|
**DeleteExecutor MUSS VOR DELETE prüfen (alle, sonst DELETE_DENIED):**
|
||||||
|
1. Schema gültig (kanonischer Payload)
|
||||||
|
2. Unterstützte Approval-Version
|
||||||
|
3. Signatur gültig (Ed25519, Public Key)
|
||||||
|
4. Key nicht revoked
|
||||||
|
5. Operation == DELETE
|
||||||
|
6. Mission stimmt
|
||||||
|
7. Delete Request stimmt
|
||||||
|
8. Object stimmt
|
||||||
|
9. Path stimmt
|
||||||
|
10. Commit stimmt
|
||||||
|
11. Provenance stimmt
|
||||||
|
12. Nonce stimmt
|
||||||
|
13. issued_at plausibel (nicht in der Zukunft)
|
||||||
|
14. expires_at nicht überschritten
|
||||||
|
15. Approval noch nicht consumed
|
||||||
|
16. State erlaubt Delete
|
||||||
|
17. kein Replay
|
||||||
|
18. kein Cross-Object-Reuse
|
||||||
|
19. kein Cross-Mission-Reuse
|
||||||
|
|
||||||
|
**Jeder Fehler → DELETE_DENIED. KEIN Tolaria HTTP DELETE.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. TOCTOU / RACE SAFETY (Phase 9)
|
||||||
|
|
||||||
|
- **VERIFY → STATE CHANGE → DELETE:** Zwischen Verification und Mutation gibt es ein Fenster.
|
||||||
|
- **Lösung:** Atomare **Reservation** in der DB: Approval-Status `APPROVED → RESERVED`
|
||||||
|
(mit `execution_attempt_id` + `idempotency_key`) in einer SQLite-Transaktion, BEVOR
|
||||||
|
der Tolaria-DELETE ausgeführt wird. Nur eine Reservation pro Approval möglich.
|
||||||
|
- **DB Transaction:** `UPDATE ... SET approval_status='RESERVED' WHERE approval_status='APPROVED'`
|
||||||
|
— atomar, nur wenn noch APPROVED.
|
||||||
|
- **Crash before delete:** Approval bleibt RESERVED → Recovery: prüfen, ob DELETE
|
||||||
|
tatsächlich ausgeführt wurde (Read-Back). Wenn nicht → zurück zu APPROVED (oder
|
||||||
|
neuer Attempt mit neuem idempotency_key).
|
||||||
|
- **Crash after delete before ledger:** Read-Back zeigt Ziel absent → Ledger nachtragen,
|
||||||
|
Approval → CONSUMED.
|
||||||
|
- **Retry:** Idempotenz via `idempotency_key` — gleicher Attempt wird nicht doppelt ausgeführt.
|
||||||
|
- **Uncertain network result:** **DELETE_OUTCOME_UNKNOWN** — der Executor darf bei
|
||||||
|
Unsicherheit NICHT blind wiederholen. Er meldet OUTCOME_UNKNOWN und wartet auf
|
||||||
|
manuelle Klärung (Read-Back).
|
||||||
|
|
||||||
|
**Recovery State für DELETE_OUTCOME_UNKNOWN:** Approval bleibt RESERVED; ein manueller
|
||||||
|
Read-Back entscheidet, ob CONSUMED (Ziel absent) oder zurück zu APPROVED (Ziel noch da).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. SINGLE USE (Phase 10)
|
||||||
|
|
||||||
|
**Approval Lifecycle:**
|
||||||
|
```
|
||||||
|
CREATED → VERIFIED → RESERVED → EXECUTED → CONSUMED
|
||||||
|
```
|
||||||
|
|
||||||
|
| Zustand | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| **INVALID** | DELETE_DENIED (Signatur/Binding-Fehler) |
|
||||||
|
| **EXPIRED** | DELETE_DENIED (expires_at überschritten) |
|
||||||
|
| **REVOKED** | DELETE_DENIED (Key oder Approval revoked) |
|
||||||
|
| **FAILED** | DELETE_DENIED (Execution-Fehler) |
|
||||||
|
| **OUTCOME_UNKNOWN** | Kein blinder Retry; manuelle Klärung |
|
||||||
|
|
||||||
|
**Eine Approval darf nach CONSUMED niemals erneut ausführbar sein.** Der consumed-check
|
||||||
|
ist Teil der Verification (§8, Punkt 15) und der Reservation (§9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. STORAGE (Phase 11)
|
||||||
|
|
||||||
|
**Was wird gespeichert:**
|
||||||
|
- Payload (kanonisch)
|
||||||
|
- Signature (Ed25519, Base64-URL)
|
||||||
|
- Public Key ID (KEY_ID)
|
||||||
|
- Verification Result (PASS/DENIED + Reason)
|
||||||
|
- consumed_at
|
||||||
|
- execution_attempt (attempt_id, idempotency_key)
|
||||||
|
- Result (DELETE_OK / DELETE_OUTCOME_UNKNOWN / DELETE_DENIED)
|
||||||
|
|
||||||
|
**Was wird NICHT gespeichert:**
|
||||||
|
- Private Key
|
||||||
|
- Signing Secret
|
||||||
|
|
||||||
|
**DB-Manipulationsrisiko:** Eine DB-Zeile allein darf keine gültige Approval erzeugen.
|
||||||
|
Selbst vollständige Schreibkontrolle über die Approval-DB darf ohne Christians
|
||||||
|
Private Key keine neue gültige Approval erzeugen können (Signatur fehlt → DENIED).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. ISOLATED IMPLEMENTATION (Phase 12)
|
||||||
|
|
||||||
|
> ⚠️ NUR Code + Tests. KEIN produktives Deployment.
|
||||||
|
|
||||||
|
**Modular (keine Vermischung mit Tolaria HTTP):**
|
||||||
|
- `approval_payload.py` — kanonischer Payload, Canonicalization, Encoding, Hash
|
||||||
|
- `approval_signature.py` — Ed25519 sign/verify (synthetische Test-Keypairs)
|
||||||
|
- `approval_verifier.py` — Verification-Logik (alle §8-Checks)
|
||||||
|
- `approval_state.py` — Lifecycle (CREATED→VERIFIED→RESERVED→EXECUTED→CONSUMED), Single-Use, Reservation
|
||||||
|
|
||||||
|
**Bestehende C5-Invarianten erhalten.** Keine Änderung an Tolaria-HTTP-Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. TEST CONTRACT (Phase 13)
|
||||||
|
|
||||||
|
> ⚠️ NUR synthetische Test-Keypairs. KEINE echten Keys.
|
||||||
|
|
||||||
|
**T1–T30 (siehe Freigabe):**
|
||||||
|
- T1 gültige Christian-Approval → PASS
|
||||||
|
- T2 falsche Signatur → DENIED
|
||||||
|
- T3 falscher Public Key → DENIED
|
||||||
|
- T4 Payload nach Signatur verändert → DENIED
|
||||||
|
- T5 Object verändert → DENIED
|
||||||
|
- T6 Path verändert → DENIED
|
||||||
|
- T7 Commit verändert → DENIED
|
||||||
|
- T8 Provenance verändert → DENIED
|
||||||
|
- T9 Mission verändert → DENIED
|
||||||
|
- T10 Request-ID verändert → DENIED
|
||||||
|
- T11 Nonce verändert → DENIED
|
||||||
|
- T12 expired → DENIED
|
||||||
|
- T13 future-issued invalid → DENIED
|
||||||
|
- T14 falsche Operation → DENIED
|
||||||
|
- T15 unbekannte Version → DENIED
|
||||||
|
- T16 malformed payload → DENIED
|
||||||
|
- T17 malformed signature → DENIED
|
||||||
|
- T18 Replay → DENIED
|
||||||
|
- T19 consumed Approval → DENIED
|
||||||
|
- T20 cross-object reuse → DENIED
|
||||||
|
- T21 cross-mission reuse → DENIED
|
||||||
|
- T22 DB fake row ohne Signature → DENIED
|
||||||
|
- T23 DB fake row mit erfundener Signature → DENIED
|
||||||
|
- T24 DELETE Executor kann keine Approval erzeugen
|
||||||
|
- T25 RQ besitzt keinen Signing Key
|
||||||
|
- T26 SAVE Executor besitzt keinen Signing Key
|
||||||
|
- T27 Logs enthalten keinen Private Key
|
||||||
|
- T28 DB enthält keinen Private Key
|
||||||
|
- T29 Retry nach confirmed delete führt nicht zu zweitem Delete
|
||||||
|
- T30 OUTCOME_UNKNOWN führt nicht zu blindem Retry
|
||||||
|
|
||||||
|
**Adversarial:** canonicalization ambiguity, duplicate JSON keys, Unicode normalization,
|
||||||
|
path normalization, signature substitution, key-id substitution, oversized payload,
|
||||||
|
malformed timestamps, nonce collision, stale state, concurrent execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. TEST SENSITIVITY (Phase 14)
|
||||||
|
|
||||||
|
**Mutationen (jede muss Tests ROT machen):**
|
||||||
|
- A Signaturprüfung entfernen
|
||||||
|
- B Object Binding entfernen
|
||||||
|
- C Commit Binding entfernen
|
||||||
|
- D Nonce Binding entfernen
|
||||||
|
- E Expiry entfernen
|
||||||
|
- F consumed-check entfernen
|
||||||
|
- G Operation-check entfernen
|
||||||
|
- H Mission Binding entfernen
|
||||||
|
- I Provenance Binding entfernen
|
||||||
|
- J Replay-Schutz entfernen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. REGRESSION (Phase 15)
|
||||||
|
|
||||||
|
Volle relevante C5-Test-Suite. Keine bestehenden Sicherheitsinvarianten abschwächen.
|
||||||
|
Baseline-Fails separat beweisen. Keine unrelated Reparaturen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. SECURITY REVIEW (Phase 16)
|
||||||
|
|
||||||
|
- NO_PRIVATE_KEY_IN_REPO / ENV / DB / LOGS
|
||||||
|
- RQ_CANNOT_SIGN / HERMES_CANNOT_SIGN / RAIN_CANNOT_SIGN / SAVE_EXECUTOR_CANNOT_SIGN / DELETE_EXECUTOR_CANNOT_SIGN
|
||||||
|
- DELETE_EXECUTOR_VERIFY_ONLY
|
||||||
|
- SIGNATURE_FAIL_CLOSED / REPLAY_FAIL_CLOSED / EXPIRY_FAIL_CLOSED / STATE_FAIL_CLOSED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. COMMIT / PUSH (Phase 17)
|
||||||
|
|
||||||
|
Nur AUTH.3D-relevante Dateien. Vor Commit: git diff, git status, Secret-Scan,
|
||||||
|
Private-Key-Pattern-Scan. Keine echten Keys. Tests nur mit synthetischen Test-Keypairs.
|
||||||
|
FF-only Push. HEAD == origin/main verifizieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. FRESH CHECKER (Phase 18)
|
||||||
|
|
||||||
|
> ⚠️ Wird als unabhängiger Subagent ausgeführt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. FINAL REPORT (Phase 19)
|
||||||
|
|
||||||
|
> ⚠️ Wird nach Fresh Checker ausgefüllt.
|
||||||
714
tolaria/tolaria-write-auth/AUTH3E_DESIGN.md
Normal file
714
tolaria/tolaria-write-auth/AUTH3E_DESIGN.md
Normal file
|
|
@ -0,0 +1,714 @@
|
||||||
|
# AUTH.3E — EXECUTOR COMMAND CHANNEL + RUNTIME BOUNDARY CONTRACT
|
||||||
|
|
||||||
|
**Status:** DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY (KEIN produktives Deployment)
|
||||||
|
**Datum:** 2026-08-27
|
||||||
|
**Mission Type:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY
|
||||||
|
**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B + AUTH.3C + AUTH.3D (9c8d239) = COMPLETE
|
||||||
|
|
||||||
|
> ⚠️ Dies ist ein **Design-/Code-/Test-Dokument**. Es wird KEIN produktiver Umbau
|
||||||
|
> ausgeführt, kein Container erstellt, keine Compose/Network/Volume/DB geändert,
|
||||||
|
> kein echtes Credential erzeugt, keine ENV geändert, kein Deployment, keine
|
||||||
|
> produktive Tolaria-Mutationsprobe, keine Hermes-Autonomie. Bei Zweifel:
|
||||||
|
> FAIL CLOSED + HARD STOP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CURRENT RUNTIME DISCOVERY (Phase 2) — rekonstruiert (READ-ONLY)
|
||||||
|
|
||||||
|
> Keine Annahmen. UNKNOWN bleibt UNKNOWN. Alle Fakten aus Code-Reading verifiziert.
|
||||||
|
|
||||||
|
### 1a. SAVE-Pfad: RQ/Hermes → C5 CLI → C5CPropagator → TolariaClient.write()
|
||||||
|
|
||||||
|
```
|
||||||
|
RQ/Hermes (Container 06a4a73573d0, User hermes uid 10010)
|
||||||
|
│
|
||||||
|
├─ [Entry Point] rq_c5_cli.py (argparse CLI, manuell aufgerufen)
|
||||||
|
│ KEIN CLI-Command führt C5CPropagator mit save_token aus (verifiziert)
|
||||||
|
│
|
||||||
|
├─ [Library] rq_c5c.py C5CPropagator (Z.497)
|
||||||
|
│ __init__(store, reader, client=None, ...) → client = client or TolariaClient()
|
||||||
|
│ Default: credential-frei (kein save_token)
|
||||||
|
│ write() → self.client.write(vault_path, after_content) (Z.638)
|
||||||
|
│
|
||||||
|
└─ [Library] rq_c5c.py TolariaClient.write() (Z.322)
|
||||||
|
token = self._require_token(self.save_token, "SAVE") # fail-closed
|
||||||
|
→ POST /api/vault/save (Bearer save_token)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boundary-Fakten (SAVE):**
|
||||||
|
- **Process Boundary:** C5CPropagator läuft im RQ/Hermes-Prozess (kein separater Prozess).
|
||||||
|
- **Container Boundary:** RQ-Container `06a4a73573d0` (User `hermes`, uid 10010, gid 10000).
|
||||||
|
- **Filesystem Boundary:** `/opt/data` (RQ-Container-Dateisystem); C5-DB `/opt/data/c5c_live/c5a.db`.
|
||||||
|
- **ENV Boundary:** `TOLARIA_SAVE_TOKEN` ABSENT im RQ-Container (verifiziert). `C5A_DB` steuert DB-Pfad.
|
||||||
|
- **Network Boundary:** Tolaria `187.124.31.123:5173` (auch 10.0.4.1/10.0.9.1); Forgejo 10.0.4.1:3000/10.0.9.1:3000.
|
||||||
|
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Commit-State, ObjectChanges, Approvals, Ledger).
|
||||||
|
- **Forgejo-Zugriff:** GitReader liest Repo `nexo312/trading-system-docs` (Forgejo-Git-Push-Key).
|
||||||
|
- **Tolaria-Zugriff:** `write()` (SAVE) via `TolariaClient`.
|
||||||
|
- **Approval-DB-Zugriff:** C5AStore (delete_approvals-Tabelle) — SAVE-Pfad liest/schreibt sie nicht aktiv.
|
||||||
|
- **Benötigte Inputs:** Commit-SHA, ObjectChanges, Source-/Provenance-Daten aus Forgejo.
|
||||||
|
- **Erzeugte Outputs:** Tolaria-SAVE, C5-State-Update (PROPAGATING→VERIFYING→UPDATING_SEARCH).
|
||||||
|
|
||||||
|
### 1b. DELETE-Pfad: RQ/Hermes → C5 CLI → DeleteExecutor → Approval Validation → TolariaClient.delete()
|
||||||
|
|
||||||
|
```
|
||||||
|
RQ/Hermes (Container 06a4a73573d0)
|
||||||
|
│
|
||||||
|
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_approve (Z.355) → persistiert Human-Approval
|
||||||
|
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_execute (Z.424) → führt DELETE aus
|
||||||
|
├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_replay (Z.458) → idempotenter Replay
|
||||||
|
│
|
||||||
|
├─ [Helper] rq_c5_cli.py _delete_executor (Z.344)
|
||||||
|
│ TolariaClient(base_url, delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN))
|
||||||
|
│ → liest NUR DELETE-Token (kein SAVE-Token)
|
||||||
|
│
|
||||||
|
├─ [Library] rq_c5_delete.py DeleteExecutor (Z.83)
|
||||||
|
│ __init__(store, client=None) → client = client or TolariaClient() (Default credential-frei)
|
||||||
|
│ execute(commit_sha) (Z.192):
|
||||||
|
│ Gate 1: ObjectChange existiert, operation == DELETE
|
||||||
|
│ Gate 2: Commit-Status == ST_DELETE_APPROVED
|
||||||
|
│ Gate 3: Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad)
|
||||||
|
│ Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch)
|
||||||
|
│ → _do_delete() → self.client.delete() (DELETE)
|
||||||
|
│
|
||||||
|
└─ [Library] rq_c5c.py TolariaClient.delete() (Z.~352)
|
||||||
|
token = self._require_token(self.delete_token, "DELETE") # fail-closed
|
||||||
|
→ POST /api/vault/delete (Bearer delete_token)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boundary-Fakten (DELETE):**
|
||||||
|
- **Process Boundary:** DeleteExecutor läuft im RQ/Hermes-Prozess (kein separater Prozess).
|
||||||
|
- **Container Boundary:** RQ-Container `06a4a73573d0` — **SELBER Container wie SAVE**.
|
||||||
|
- **Filesystem Boundary:** `/opt/data`; C5-DB `/opt/data/c5c_live/c5a.db`.
|
||||||
|
- **ENV Boundary:** `TOLARIA_DELETE_TOKEN` ABSENT im RQ-Container (verifiziert).
|
||||||
|
- **Network Boundary:** Tolaria `187.124.31.123:5173`.
|
||||||
|
- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Approvals, Ledger, Commit-State).
|
||||||
|
- **Forgejo-Zugriff:** GitReader (Provenance-/Object-Daten).
|
||||||
|
- **Tolaria-Zugriff:** `delete()` (DELETE) via `TolariaClient`.
|
||||||
|
- **Approval-DB-Zugriff:** C5AStore delete_approvals-Tabelle (lesen + USED markieren).
|
||||||
|
- **Benötigte Inputs:** Commit-SHA, persistierte Approval, ObjectChange, Nonce.
|
||||||
|
- **Erzeugte Outputs:** Tolaria-DELETE, Approval→USED, Commit→ST_UPDATING_SEARCH.
|
||||||
|
|
||||||
|
### 1c. Kernbefund (bestätigt AUTH.3B/3C)
|
||||||
|
**SAVE- und DELETE-Caller laufen im SELBEN Prozess/Container** (`rq_c5_cli.py`, RQ-Container
|
||||||
|
`06a4a73573d0`). **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY.** Sobald Credentials
|
||||||
|
produktiv in denselben Container injiziert würden, wäre RQ_SAVE_ACCESS/DELETE_ACCESS
|
||||||
|
technisch NICHT mehr NO. → AUTH.4 BLOCKED.
|
||||||
|
|
||||||
|
### 1d. DB-Schema (C5AStore, `c5a.db`) — rekonstruiert
|
||||||
|
- **Tabellen:** `commits` (commit_sha, parent_sha, discovered_at, sequence, status, retry_count, last_error, last_error_code, updated_at), `objects` (commit_sha, object_id, path_before, path_after, operation, content_hash_before/after, metadata_hash_before/after, representation, state, reason_code; UNIQUE(commit_sha, object_id, operation)), `delete_approvals` (approval_id, workflow_commit, object_change_id, object_id, path, operation='DELETE', approval_status APPROVED/USED/REVOKED, approved_by, approval_nonce, approved_at, used_at; UNIQUE(workflow_commit, object_change_id, object_id, path)), `kv` (key, value — für search_source_provenance).
|
||||||
|
- **State Machine (commits.status):** DISCOVERED → VALIDATING → READY → PROPAGATING_TOLARIA → VERIFYING_TOLARIA → UPDATING_SEARCH → VERIFYING_SEARCH → APPLIED; plus RETRY_PENDING, FAILED, DEAD, HUMAN_REVIEW_REQUIRED, WAITING_FOR_PREDECESSOR, DELETE_APPROVED, DELETING.
|
||||||
|
- **Locks/Transactions:** SQLite; `_init_schema` erstellt Tabellen; `upsert_commit` nutzt `INSERT ... ON CONFLICT DO UPDATE` (idempotent); `add_object_change` nutzt `INSERT OR IGNORE` (idempotent per UNIQUE). Kein expliziter WAL-Modus im Code gesehen (Default SQLite).
|
||||||
|
- **Idempotenz:** `SyncStateMachine.idempotency_check` (commit_sha+object_id+operation; ALREADY_APPLIED/ALREADY_AT_TARGET/RETRY_SAFE/CONFLICT); `add_object_change` INSERT OR IGNORE; `upsert_commit` ON CONFLICT.
|
||||||
|
- **Retry:** `retry_commit` (increment_retry, max_retries → DEAD, sonst RETRY_PENDING + backoff); `_is_retryable` in C5C (nur TolariaUnavailableError retryable).
|
||||||
|
- **Replay:** `c5-delete-replay` (idempotent, nutzt denselben `_delete_executor`); Approval single-use (→ USED).
|
||||||
|
- **Recovery:** `c5e-recover`, `c5e-reconcile` (C5E); `c5e-replay --allow-writes` nur Test-/Canary-Scope.
|
||||||
|
- **Provenance:** `persist_search_source_provenance` (kv-Tabelle, key `search_source_provenance:<workflow_commit_sha>`); `get_source_provenance`.
|
||||||
|
- **Mission Binding:** **FEHLT** in C5A-Commits/ObjectChanges (kein mission_id-Feld). Nur in AUTH.3D-Approval-Payload (mission_id) vorhanden.
|
||||||
|
- **Object Binding:** `objects.object_id` (UNIQUE mit commit_sha+operation); Approval object_id-bound.
|
||||||
|
- **Commit Binding:** `commits.commit_sha`; Approval workflow_commit-bound.
|
||||||
|
|
||||||
|
### 1e. AUTH.3D-Approval (rekonstruiert, `approval_payload.py`/`approval_state.py`/`approval_verifier.py`)
|
||||||
|
- **Payload (12 kanonische Felder):** approval_version, approval_id, mission_id, delete_request_id, operation, object_id, vault_path, expected_commit, expected_provenance_hash, nonce, issued_at, expires_at.
|
||||||
|
- **Lifecycle:** CREATED → VERIFIED → RESERVED → EXECUTED → CONSUMED (atomare Reservation, TOCTOU-sicher).
|
||||||
|
- **Verifier:** verify-only (Ed25519 PUBLIC KEY ONLY), importiert nur key_id, verify.
|
||||||
|
- **Signature:** `approval_signature.py` enthält sign()/private_key_to_pem() NUR zur Simulation; KEIN Private Key im Stack.
|
||||||
|
- **Bindings:** Object, Commit, Path, Operation, Single-Use, Nonce, Expiry, Mission (in Payload).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. COMMAND CHANNEL OPTIONS (Phase 3) — Bewertung
|
||||||
|
|
||||||
|
> Keine neue Infrastruktur bevorzugen, wenn eine einfachere bestehende Primitive
|
||||||
|
> die Sicherheitsanforderungen erfüllt.
|
||||||
|
|
||||||
|
| Kriterium | A) Shared SQLite Job DB | B) Getrennte SQLite Inbox pro Executor | C) Filesystem Inbox / atomic rename | D) Interner HTTP Executor Endpoint | E) RabbitMQ / Broker | F) Unix Socket / IPC | G) C5 State Machine (bestehend) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| SECURITY | Mittel | **Hoch** | Mittel | Mittel | Hoch | Mittel | **Hoch** |
|
||||||
|
| COMPLEXITY | Niedrig | **Niedrig** | Niedrig | Mittel | **Hoch** | Mittel | **Niedrig** |
|
||||||
|
| FAIL_CLOSED | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** |
|
||||||
|
| AUDITABILITY | Hoch | **Hoch** | Mittel | Hoch | Hoch | Mittel | **Hoch** |
|
||||||
|
| IDEMPOTENCY | Ja | **Ja** | Teilweise | Ja | Ja | Ja | **Ja** |
|
||||||
|
| REPLAY_RESISTANCE | Teilweise | **Ja** | Teilweise | Ja | Ja | Ja | **Ja** |
|
||||||
|
| CRASH_RECOVERY | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** |
|
||||||
|
| CONCURRENCY | Mittel (Lock) | **Hoch (getrennte DBs)** | Mittel | Hoch | Hoch | Mittel | **Hoch** |
|
||||||
|
| RQ_SCOPE_ESCALATION | Nein | **Nein** | Nein | Nein | Nein | Nein | **Nein** |
|
||||||
|
| SECRET_EXPOSURE | Kein Secret | **Kein Secret** | Kein Secret | Kein Secret | Kein Secret | Kein Secret | **Kein Secret** |
|
||||||
|
| FILESYSTEM_BOUNDARY | Geteilt | **Getrennt** | Getrennt | Getrennt | Getrennt | Getrennt | **Getrennt** |
|
||||||
|
| NETWORK_BOUNDARY | Kein Netz | **Kein Netz** | Kein Netz | Netz nötig | Netz nötig | Kein Netz | **Kein Netz** |
|
||||||
|
| AUTH3D_COMPATIBILITY | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** |
|
||||||
|
| CURRENT_C5_COMPATIBILITY | Mittel | **Hoch** | Mittel | Niedrig | **Niedrig** | Niedrig | **Hoch** |
|
||||||
|
| AUTH4_DEPLOYMENT_COMPLEXITY | Niedrig | **Niedrig** | Niedrig | Mittel | **Hoch** | Mittel | **Niedrig** |
|
||||||
|
|
||||||
|
**Bewertung:**
|
||||||
|
- **OPTION B (getrennte SQLite Inbox pro Executor)** ist die **bevorzugte** Wahl: Sie erfüllt
|
||||||
|
die geforderte **GETRENNTE INBOX/STATE BOUNDARY** (SAVE-Executor claimt nur SAVE-Jobs,
|
||||||
|
DELETE-Executor nur DELETE-Jobs), nutzt vorhandene SQLite-Primitive (keine neue
|
||||||
|
Infrastruktur), ist fail-closed, auditierbar, idempotent, replay-resistent und
|
||||||
|
crash-recoverable. Getrennte DB-Dateien (`c5a_save.db`/`c5a_delete.db`) vermeiden
|
||||||
|
Lock-Konflikte zwischen SAVE- und DELETE-Executor.
|
||||||
|
- **OPTION A (Shared SQLite Job DB)** ist einfacher, aber teilt die DB → schwächere
|
||||||
|
DB-Boundary (SAVE-Executor könnte DELETE-Jobs sehen, wenn Scope nicht strikt getrennt).
|
||||||
|
- **OPTION C (Filesystem Inbox)** ist einfach, aber weniger robust (kein atomarer
|
||||||
|
Claim/Lease, schwächere Idempotenz/Replay-Resistenz).
|
||||||
|
- **OPTION D (HTTP Endpoint)** erfordert neue Netzwerk-Endpoints → Scope-Creep.
|
||||||
|
- **OPTION E (RabbitMQ/Broker)** erfordert neue Infrastruktur → **abgelehnt** (nicht vorhanden).
|
||||||
|
- **OPTION F (Unix Socket)** erfordert neue IPC-Infrastruktur → Scope-Creep.
|
||||||
|
- **OPTION G (C5 State Machine)** ist die bestehende Primitive für DELETE (Approval
|
||||||
|
persistiert, Nonce, Single-Use, Replay-Schutz) und wird als **Komplement** zu B genutzt:
|
||||||
|
Der DELETE-Job verweist auf die persistierte AUTH.3D-Approval; der DELETE-Executor
|
||||||
|
validiert Job UND Approval unabhängig.
|
||||||
|
|
||||||
|
**Empfehlung:** **OPTION B (getrennte SQLite Inbox pro Executor) als Command Channel**,
|
||||||
|
kombiniert mit **OPTION G (C5 State Machine)** für die DELETE-Approval-Autorität.
|
||||||
|
Keine neue Infrastruktur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. PREFERRED ARCHITECTURAL PROPERTY (Phase 4)
|
||||||
|
|
||||||
|
**GETRENNTE INBOX / STATE BOUNDARIES.**
|
||||||
|
|
||||||
|
```
|
||||||
|
RQ/Hermes (Container 06a4a73573d0) — credential-frei
|
||||||
|
│ credential-freier Job-Request (kein Secret)
|
||||||
|
▼
|
||||||
|
+---------------------------+ +-----------------------------+
|
||||||
|
| c5-save-executor (Cont.) | | c5-delete-executor (Cont.) |
|
||||||
|
| SAVE credential | | DELETE credential |
|
||||||
|
| NO DELETE | | NO SAVE |
|
||||||
|
| claimt NUR SAVE-Jobs | | claimt NUR DELETE-Jobs |
|
||||||
|
| c5a_save.db (Inbox) | | c5a_delete.db (Inbox) |
|
||||||
|
+---------------------------+ +-----------------------------+
|
||||||
|
│ SAVE (Bearer save_token) │ DELETE (Bearer delete_token)
|
||||||
|
▼ ▼
|
||||||
|
Tolaria Server (187.124.31.123:5173) — erwartet SAVE+DELETE-Verifier
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kein generischer Dispatcher.** Kein `command="..."`, `endpoint="..."`, `url="..."`,
|
||||||
|
`method="..."`, `shell="..."`, `python="..."`, `handler="..."`. Keine RCE-artige Semantik.
|
||||||
|
|
||||||
|
**Job Types als geschlossene Allowlist:**
|
||||||
|
- `C5_SAVE_OBJECT`
|
||||||
|
- `C5_DELETE_OBJECT`
|
||||||
|
|
||||||
|
Gegen die reale C5-Architektur geprüft: Die C5-Operationen sind `OP_CREATE`,
|
||||||
|
`OP_CONTENT_UPDATE`, `OP_METADATA_UPDATE`, `OP_STATE_UPDATE`, `OP_TAGS_UPDATE`,
|
||||||
|
`OP_SOURCE_CANONICAL_RELATION_UPDATE`, `OP_RENAME`, `OP_MOVE`, `OP_DELETE_REQUEST`,
|
||||||
|
`OP_SUPERSEDE`. Für den Command Channel werden diese auf zwei geschlossene Job-Types
|
||||||
|
abgebildet: **SAVE** (alle Nicht-DELETE-Operationen, die Tolaria-Write erfordern) und
|
||||||
|
**DELETE** (nur `OP_DELETE_REQUEST`). Kein generischer Operation-Dispatcher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SAVE JOB CONTRACT (Phase 5)
|
||||||
|
|
||||||
|
### Kanonischer SAVE Job
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_version": 1,
|
||||||
|
"job_id": "uuid4",
|
||||||
|
"mission_id": "uuid4",
|
||||||
|
"job_type": "C5_SAVE_OBJECT",
|
||||||
|
"object_id": "string",
|
||||||
|
"vault_path": "/app/vault/...",
|
||||||
|
"source_commit": "sha256",
|
||||||
|
"provenance_hash": "sha256",
|
||||||
|
"expected_state": "string",
|
||||||
|
"created_at": "ISO8601 UTC",
|
||||||
|
"idempotency_key": "uuid4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content-Entscheidung: A) Content im Job ODER B) Executor lädt selbst
|
||||||
|
**Gewählt: B) Executor rekonstruiert sicherheitsrelevante Daten selbst aus autoritativer
|
||||||
|
Source (Forgejo).**
|
||||||
|
|
||||||
|
Begründung (Manipulationsrisiko):
|
||||||
|
- Wenn Content im Job transportiert würde, könnte RQ (oder ein kompromittierter
|
||||||
|
RQ-Prozess) beliebigen Content in den Job schreiben → der SAVE-Executor würde
|
||||||
|
manipulierten Content nach Tolaria schreiben.
|
||||||
|
- **Bevorzugte Eigenschaft (aus Mission):** Executor rekonstruiert sicherheitsrelevante
|
||||||
|
Daten soweit möglich selbst aus autoritativen Quellen, statt RQ blind zu vertrauen.
|
||||||
|
- Der SAVE-Executor lädt den Content selbst aus Forgejo (source_commit + object_id →
|
||||||
|
GitReader.file_content) und validiert gegen provenance_hash/expected_state.
|
||||||
|
|
||||||
|
**RQ darf NICHT bestimmen:**
|
||||||
|
- Tolaria Base URL (Executor-Konfiguration, nicht Job)
|
||||||
|
- HTTP Method (fix: POST /api/vault/save)
|
||||||
|
- Authorization Header (Executor-Credential, nicht Job)
|
||||||
|
- Credential (Executor-Credential, nicht Job)
|
||||||
|
- beliebigen Zielendpoint (fix: /api/vault/save)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DELETE JOB CONTRACT (Phase 6)
|
||||||
|
|
||||||
|
### Kanonischer DELETE Job (strenger)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job_version": 1,
|
||||||
|
"job_id": "uuid4",
|
||||||
|
"mission_id": "uuid4",
|
||||||
|
"delete_request_id": "uuid4",
|
||||||
|
"job_type": "C5_DELETE_OBJECT",
|
||||||
|
"object_id": "string",
|
||||||
|
"vault_path": "/app/vault/...",
|
||||||
|
"expected_commit": "sha256",
|
||||||
|
"expected_provenance_hash": "sha256",
|
||||||
|
"approval_id": "uuid4",
|
||||||
|
"created_at": "ISO8601 UTC",
|
||||||
|
"idempotency_key": "uuid4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**WICHTIG: Der DELETE Job selbst ist KEINE Approval.**
|
||||||
|
|
||||||
|
DeleteExecutor muss unabhängig laden/verifizieren (AUTH.3D Approval Artifact):
|
||||||
|
- Ed25519 Signature (verify-only, PUBLIC KEY ONLY)
|
||||||
|
- mission_id
|
||||||
|
- delete_request_id
|
||||||
|
- object_id
|
||||||
|
- vault_path
|
||||||
|
- expected_commit
|
||||||
|
- expected_provenance_hash
|
||||||
|
- nonce
|
||||||
|
- expiry
|
||||||
|
- single-use state
|
||||||
|
|
||||||
|
**Job und Approval müssen exakt zusammenpassen. Mismatch → DELETE_DENIED.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. TRUST MODEL (Phase 7)
|
||||||
|
|
||||||
|
### RQ_JOB_FIELDS_TRUST_LEVEL
|
||||||
|
| Feld | SAVE | DELETE | Klassifikation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| job_version | TRUSTED (Schema) | TRUSTED (Schema) | Schema-Validierung |
|
||||||
|
| job_id | TRUSTED (Identität) | TRUSTED (Identität) | Idempotenz-Schlüssel |
|
||||||
|
| mission_id | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen Approval/Provenance |
|
||||||
|
| job_type | TRUSTED (Allowlist) | TRUSTED (Allowlist) | Geschlossene Allowlist |
|
||||||
|
| object_id | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen Forgejo/Approval |
|
||||||
|
| vault_path | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen ObjectChange/Approval + Path Safety |
|
||||||
|
| source_commit | VERIFY_AGAINST_SOT | — | Gegen Forgejo |
|
||||||
|
| provenance_hash | RECOMPUTE | VERIFY_AGAINST_SOT | Executor rechnet selbst |
|
||||||
|
| expected_state | VERIFY_AGAINST_SOT | — | Gegen Forgejo |
|
||||||
|
| expected_commit | — | VERIFY_AGAINST_SOT | Gegen Approval |
|
||||||
|
| expected_provenance_hash | — | VERIFY_AGAINST_SOT | Gegen Approval |
|
||||||
|
| approval_id | — | VERIFY_AGAINST_SOT | Gegen AUTH.3D Approval |
|
||||||
|
| delete_request_id | — | VERIFY_AGAINST_SOT | Gegen Approval |
|
||||||
|
| created_at | TRUSTED (Metadaten) | TRUSTED (Metadaten) | Audit |
|
||||||
|
| idempotency_key | TRUSTED (Idempotenz) | TRUSTED (Idempotenz) | Replay-Schutz |
|
||||||
|
|
||||||
|
**Grundsatz: DATA FROM RQ != AUTHORITY.** RQ ist Orchestrator, nicht Security Authority.
|
||||||
|
Für sicherheitskritische Felder: VERIFY_AGAINST_SOT oder RECOMPUTE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. JOB STATE MACHINE (Phase 8)
|
||||||
|
|
||||||
|
### Deterministische State Machine
|
||||||
|
```
|
||||||
|
CREATED → READY → CLAIMED → EXECUTING → SUCCEEDED
|
||||||
|
│
|
||||||
|
├→ FAILED
|
||||||
|
├→ REJECTED
|
||||||
|
└→ OUTCOME_UNKNOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erlaubte Transitionen (geschlossene Allowlist):**
|
||||||
|
- CREATED → READY (Schema validiert, Job akzeptiert)
|
||||||
|
- CREATED → REJECTED (Schema/Allowlist/Injection-Fehler)
|
||||||
|
- READY → CLAIMED (atomarer Claim)
|
||||||
|
- CLAIMED → EXECUTING (Claim bestätigt)
|
||||||
|
- EXECUTING → SUCCEEDED (bestätigter Erfolg)
|
||||||
|
- EXECUTING → FAILED (bestätigter Fehler, kein Mutationseffekt)
|
||||||
|
- EXECUTING → OUTCOME_UNKNOWN (unklares HTTP-Ergebnis)
|
||||||
|
- EXECUTING → REJECTED (Gate-Fehler während Execution)
|
||||||
|
|
||||||
|
**RETRYABLE:** Optional, nur sicher begründet. Für SAVE: RETRYABLE nur bei
|
||||||
|
TolariaUnavailableError (transient, kein Mutationseffekt). Für DELETE: **KEIN
|
||||||
|
RETRYABLE** — ein bereits gestarteter DELETE darf nicht wegen Lease-Expiry von einem
|
||||||
|
zweiten Worker wiederholt werden. OUTCOME_UNKNOWN → kein blinder Retry.
|
||||||
|
|
||||||
|
**Ungültige Transition → FAIL CLOSED** (InvalidTransitionError).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. CLAIM / LEASE / CONCURRENCY (Phase 9)
|
||||||
|
|
||||||
|
### Claim-Modell
|
||||||
|
- **worker_id:** UUID des Executors (SAVE-Executor vs DELETE-Executor).
|
||||||
|
- **claim_id:** UUID pro Claim.
|
||||||
|
- **claimed_at:** Timestamp.
|
||||||
|
- **lease_until:** Timestamp (Claim-Expiry).
|
||||||
|
- **attempt_count:** Zähler.
|
||||||
|
|
||||||
|
### Atomarer Claim
|
||||||
|
Der Claim muss **atomar** sein (SQLite `UPDATE ... WHERE status='READY' AND
|
||||||
|
(lease_until IS NULL OR lease_until < now) RETURNING ...` in einer Transaktion).
|
||||||
|
Kein TOCTOU.
|
||||||
|
|
||||||
|
### Concurrency-Prüfung
|
||||||
|
- **Zwei SAVE Worker:** Nur einer kann denselben Job atomar claimen (Status-Transition
|
||||||
|
READY→CLAIMED ist atomar).
|
||||||
|
- **Zwei DELETE Worker:** Nur einer kann denselben DELETE-Job claimen. **Ein bereits
|
||||||
|
gestarteter DELETE darf NICHT einfach wegen Lease-Expiry von einem zweiten Worker
|
||||||
|
wiederholt werden.** → DELETE-Job, der in EXECUTING ist, wird bei Lease-Expiry in
|
||||||
|
OUTCOME_UNKNOWN überführt (nicht zurück zu READY), und ein zweiter Worker darf ihn
|
||||||
|
NICHT erneut claimen. Nur ein expliziter Human-/Admin-Eingriff (AUTH.3D-Approval
|
||||||
|
erneut verifizieren) kann einen DELETE-Job aus OUTCOME_UNKNOWN reaktivieren.
|
||||||
|
- **Worker Crash nach Claim:** Lease-Expiry → Job wird für SAVE wieder claimbar
|
||||||
|
(RETRYABLE), für DELETE → OUTCOME_UNKNOWN (nicht blind wiederholen).
|
||||||
|
- **Stale Claim:** Lease-Expiry erkennt stale Claims.
|
||||||
|
- **Restart / Doppelstart / Race:** Atomarer Claim + Lease verhindern Doppelverarbeitung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. IDEMPOTENCY (Phase 10)
|
||||||
|
|
||||||
|
### SAVE
|
||||||
|
Idempotenz über: `job_id` + `idempotency_key` + `object_id` + `source_commit`/`provenance`.
|
||||||
|
- Gleicher Job (job_id) → bereits verarbeitet → kein zweiter Write.
|
||||||
|
- Gleicher idempotency_key → idempotent (kein zweiter Write).
|
||||||
|
- Gleicher object_id + source_commit → ALREADY_AT_TARGET (Ziel-Hash erreicht).
|
||||||
|
|
||||||
|
### DELETE (strenger)
|
||||||
|
- Nach bestätigtem DELETE: gleicher Job niemals erneut mutieren.
|
||||||
|
- Bei unklarem HTTP-Ergebnis: **OUTCOME_UNKNOWN. KEIN BLINDER RETRY.**
|
||||||
|
- AUTH.3D OUTCOME_UNKNOWN-Modell bleibt erhalten.
|
||||||
|
- Approval single-use (→ USED/CONSUMED) → Replay denied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. JOB AUTHENTICITY / INTEGRITY (Phase 11)
|
||||||
|
|
||||||
|
### Ist Job-DB-Manipulation durch RQ relevant?
|
||||||
|
- **Für SAVE:** Ein manipulierter Job darf niemals Scope erweitern. Da der SAVE-Executor
|
||||||
|
Content selbst aus Forgejo lädt und gegen provenance_hash/expected_state validiert,
|
||||||
|
kann ein manipulierter Job keinen beliebigen Content einschleusen. Scope bleibt
|
||||||
|
geschlossen (nur SAVE, nur erlaubter Pfad).
|
||||||
|
- **Für DELETE:** Selbst vollständige Job-DB-Schreibkontrolle darf NICHT ausreichen, um
|
||||||
|
DELETE zu autorisieren. **AUTH.3D Signature bleibt die Autorität.** Ein manipulierter
|
||||||
|
DELETE-Job ohne gültige, exakt passende AUTH.3D-Approval → DELETE_DENIED.
|
||||||
|
|
||||||
|
### Zusätzliche Schutzmechanismen (bewertet)
|
||||||
|
- **Job HMAC:** NICHT nötig für DELETE (AUTH.3D-Signatur schützt die kritische
|
||||||
|
DELETE-Autorität). Für SAVE optional, aber nicht erforderlich (Content wird
|
||||||
|
rekonstruiert).
|
||||||
|
- **DB ownership / filesystem permissions:** Ja — getrennte DB-Dateien mit
|
||||||
|
Executor-spezifischen Permissions (siehe Phase 12).
|
||||||
|
- **Immutable fields after claim:** Ja — nach CLAIMED sind job_type, object_id,
|
||||||
|
vault_path, approval_id, expected_* unveränderlich (UPDATE verboten).
|
||||||
|
- **State hash / row version / compare-and-swap:** Optional für SAVE; für DELETE
|
||||||
|
wird die AUTH.3D-Signatur als Autorität genutzt (keine unnötige Kryptografie).
|
||||||
|
|
||||||
|
**Keine unnötige Kryptografie einführen, wenn die AUTH.3D-Signatur bereits die
|
||||||
|
kritische DELETE-Autorität schützt.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. DB / FILESYSTEM BOUNDARY (Phase 12)
|
||||||
|
|
||||||
|
### Getrennte SQLite-Dateien (bestätigt AUTH.3C)
|
||||||
|
- `c5a_save.db` — SAVE-Inbox (SAVE-Jobs, SAVE-State)
|
||||||
|
- `c5a_delete.db` — DELETE-Inbox (DELETE-Jobs, DELETE-State, Approval-Referenzen)
|
||||||
|
|
||||||
|
### Zugriffsmatrix
|
||||||
|
| DB | RQ_READ | RQ_WRITE | SAVE_EXEC_READ | SAVE_EXEC_WRITE | DELETE_EXEC_READ | DELETE_EXEC_WRITE |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| c5a_save.db | Ja (Job erzeugen) | Ja (Job erzeugen) | Ja | Ja | **NEIN** | **NEIN** |
|
||||||
|
| c5a_delete.db | Ja (Job erzeugen) | Ja (Job erzeugen) | **NEIN** | **NEIN** | Ja | Ja |
|
||||||
|
|
||||||
|
**Ziel:**
|
||||||
|
- SAVE Executor braucht keinen Zugriff auf DELETE Credential/DB, außer explizit
|
||||||
|
notwendige read-only Referenzen (keine).
|
||||||
|
- DELETE Executor braucht keinen SAVE-State.
|
||||||
|
|
||||||
|
### Linux UID/GID-/Volume-Modell (für AUTH.4, NICHT produktiv ändern)
|
||||||
|
- SAVE-Executor-Container: eigener User, Volume nur für `c5a_save.db`.
|
||||||
|
- DELETE-Executor-Container: eigener User, Volume nur für `c5a_delete.db`.
|
||||||
|
- RQ-Container: Volume für beide Inbox-DBs (nur Job-Erzeugung, kein Executor-Zugriff).
|
||||||
|
- Kein geteiltes Secret-Volume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. NETWORK BOUNDARY (Phase 13)
|
||||||
|
|
||||||
|
### SAVE Executor
|
||||||
|
**ALLOW:**
|
||||||
|
- Tolaria notwendige interne Adresse (187.124.31.123:5173) — SAVE + READ
|
||||||
|
- notwendige autoritative Source (Forgejo 10.0.4.1:3000) — Source-/Provenance-Read
|
||||||
|
|
||||||
|
**DENY (soweit möglich):**
|
||||||
|
- Docker Socket
|
||||||
|
- SSH
|
||||||
|
- Host Root
|
||||||
|
- Telegram
|
||||||
|
- beliebiges Internet
|
||||||
|
- DELETE-spezifische Dienste
|
||||||
|
|
||||||
|
### DELETE Executor
|
||||||
|
**ALLOW:**
|
||||||
|
- Tolaria (187.124.31.123:5173) — DELETE
|
||||||
|
- notwendige Approval-/State-Source (Forgejo 10.0.4.1:3000) — Object/Commit-Read
|
||||||
|
|
||||||
|
**DENY:**
|
||||||
|
- Docker Socket
|
||||||
|
- SSH
|
||||||
|
- Host Root
|
||||||
|
- Telegram
|
||||||
|
- beliebiges Internet
|
||||||
|
- SAVE-spezifische Credentials
|
||||||
|
|
||||||
|
### Forgejo-Runtime-Frage
|
||||||
|
**Wird Forgejo wirklich zur Runtime benötigt?**
|
||||||
|
- **SAVE:** JA — der SAVE-Executor lädt Content selbst aus Forgejo (source_commit +
|
||||||
|
object_id → GitReader.file_content). Forgejo ist die autoritative Source.
|
||||||
|
- **DELETE:** JA (für Object/Commit-Read zur Validierung). Aber: Falls der DELETE-Job
|
||||||
|
alle nötigen Daten (object_id, vault_path, expected_commit, expected_provenance_hash)
|
||||||
|
bereits enthält und die AUTH.3D-Approval diese exakt bestätigt, könnte Forgejo-Read
|
||||||
|
optional sein. **Empfehlung:** Forgejo-Read für DELETE beibehalten (Defense in depth),
|
||||||
|
aber als read-only, nicht als Write.
|
||||||
|
|
||||||
|
**Falls nicht nötig: KEIN Forgejo Runtime Access.** Für SAVE ist er nötig (Content-
|
||||||
|
Rekonstruktion). Für DELETE ist er nötig (Object/Commit-Validierung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. SECRET BOUNDARY (Phase 14)
|
||||||
|
|
||||||
|
### Zielmatrix (beweisen)
|
||||||
|
| Akteur | SAVE_TOKEN | DELETE_TOKEN | PRIVATE_SIGNING_KEY | PUBLIC_VERIFY_KEY |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| RQ | ABSENT | ABSENT | ABSENT | ABSENT |
|
||||||
|
| SAVE Executor | **PRESENT** | ABSENT | ABSENT | ABSENT |
|
||||||
|
| DELETE Executor | ABSENT | **PRESENT** | ABSENT | **PRESENT** |
|
||||||
|
| Rain | ABSENT | ABSENT | ABSENT | ABSENT |
|
||||||
|
| Tolaria Server | PRESENT (verifier) | PRESENT (verifier) | ABSENT | ABSENT |
|
||||||
|
|
||||||
|
**Beweis (Design):**
|
||||||
|
- RQ: credential-frei (kein SAVE/DELETE-Token, kein Private Key). Nur Job-Erzeugung.
|
||||||
|
- SAVE Executor: erhält NUR SAVE-Token (injiziert), kein DELETE-Token, kein Private Key.
|
||||||
|
- DELETE Executor: erhält NUR DELETE-Token + PUBLIC_VERIFY_KEY, kein SAVE-Token, kein
|
||||||
|
Private Key.
|
||||||
|
- Rain: kein Zugriff auf irgendein Secret (separate Container).
|
||||||
|
- Tolaria Server: hält SAVE+DELETE-Verifier-Secrets (eigene ENV), KEIN Christians
|
||||||
|
Private Key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. COMMAND INJECTION DEFENSE (Phase 15)
|
||||||
|
|
||||||
|
Jobs dürfen niemals enthalten oder ausführen:
|
||||||
|
- shell commands
|
||||||
|
- arbitrary executable
|
||||||
|
- Python snippets
|
||||||
|
- URLs
|
||||||
|
- HTTP headers
|
||||||
|
- credentials
|
||||||
|
- SQL
|
||||||
|
- filesystem absolute targets (außerhalb erlaubten Schemas)
|
||||||
|
- Docker commands
|
||||||
|
- SSH commands
|
||||||
|
|
||||||
|
**Enum + typed schema:**
|
||||||
|
- `job_type` ist ein Enum (`C5_SAVE_OBJECT` | `C5_DELETE_OBJECT`), kein freier String.
|
||||||
|
- Alle Felder sind typisiert (uuid, sha256-hex, ISO8601, enum).
|
||||||
|
- **Unknown job_type → REJECTED.**
|
||||||
|
- **Extra privileged fields → REJECTED** (Schema-Validierung lehnt unbekannte Felder ab).
|
||||||
|
- **Malformed schema → REJECTED.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. PATH SAFETY COMPOSITION (Phase 16)
|
||||||
|
|
||||||
|
AUTH.2 Path Safety bleibt serverseitig zwingend. Executor muss zusätzlich eigene
|
||||||
|
Pfadprüfung durchführen.
|
||||||
|
|
||||||
|
**Defense in depth:**
|
||||||
|
```
|
||||||
|
RQ Job → Executor validation → Tolaria AUTH.2 validation
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Executor validation:** vault_path wird gegen das erlaubte Schema geprüft
|
||||||
|
(kein `..`, kein absoluter Pfad außerhalb `/app/vault`, keine Symlink-Escapes,
|
||||||
|
keine Unicode-Ambiguität, keine encoded traversal).
|
||||||
|
- **Tolaria AUTH.2 validation:** serverseitige Path Safety (bestehend, zwingend).
|
||||||
|
- **Kein Caller darf AUTH.2 Path Safety ersetzen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. AUTH.3D COMPOSITION (Phase 17)
|
||||||
|
|
||||||
|
Für DELETE muss gelten (alle Faktoren, keiner ersetzt einen anderen):
|
||||||
|
```
|
||||||
|
VALID_JOB
|
||||||
|
AND VALID_STATE
|
||||||
|
AND VALID_AUTH3D_SIGNATURE
|
||||||
|
AND VALID_NONCE
|
||||||
|
AND VALID_EXPIRY
|
||||||
|
AND NOT_CONSUMED
|
||||||
|
AND VALID_OBJECT
|
||||||
|
AND VALID_PATH
|
||||||
|
AND VALID_COMMIT
|
||||||
|
AND VALID_PROVENANCE
|
||||||
|
AND DELETE_CREDENTIAL
|
||||||
|
```
|
||||||
|
erst dann: **Tolaria DELETE.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. FAILURE MODEL (Phase 18)
|
||||||
|
|
||||||
|
| Szenario | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| DB unavailable | FAIL CLOSED — kein Write ohne State |
|
||||||
|
| DB locked | SQLite-Lock → Retry/Fehler; kein Write ohne State |
|
||||||
|
| malformed job | REJECTED (Schema) |
|
||||||
|
| unknown version | REJECTED |
|
||||||
|
| unknown type | REJECTED |
|
||||||
|
| state mismatch | FAIL CLOSED (InvalidTransitionError) |
|
||||||
|
| source unavailable | FAIL CLOSED (kein Content-Rekonstruktions-Write) |
|
||||||
|
| Tolaria unavailable | SAVE: RETRYABLE (transient); DELETE: OUTCOME_UNKNOWN |
|
||||||
|
| 401 | FAIL CLOSED (Credential/Scope-Fehler) |
|
||||||
|
| 403 | FAIL CLOSED |
|
||||||
|
| 404 | FAIL CLOSED (Ziel fehlt) |
|
||||||
|
| 409 | FAIL CLOSED (Konflikt) |
|
||||||
|
| 500 | FAIL CLOSED |
|
||||||
|
| timeout | SAVE: RETRYABLE; DELETE: OUTCOME_UNKNOWN |
|
||||||
|
| connection reset | SAVE: RETRYABLE; DELETE: OUTCOME_UNKNOWN |
|
||||||
|
| worker crash | Lease-Expiry; SAVE: wieder claimbar; DELETE: OUTCOME_UNKNOWN |
|
||||||
|
| process kill | dito |
|
||||||
|
| container restart | Zustand aus DB; keine Sicherheitsinvariante verloren |
|
||||||
|
| duplicate job | Idempotenz (job_id/idempotency_key) |
|
||||||
|
| duplicate worker | Atomarer Claim verhindert Doppelverarbeitung |
|
||||||
|
| approval unavailable | DELETE_DENIED |
|
||||||
|
| approval invalid | DELETE_DENIED |
|
||||||
|
| approval expired | DELETE_DENIED |
|
||||||
|
| credential missing | FAIL CLOSED (kein HTTP) |
|
||||||
|
|
||||||
|
**Security-Grundsatz: Unsicherheit darf nicht zu privilegierter Mutation führen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. AUDIT MODEL (Phase 19)
|
||||||
|
|
||||||
|
Jeder Job benötigt Audit Trail. Mindestens:
|
||||||
|
- job_id, mission_id, job_type, object_id
|
||||||
|
- state transitions (mit Timestamps)
|
||||||
|
- worker_id, attempt
|
||||||
|
- timestamps
|
||||||
|
- result code
|
||||||
|
- approval_id (bei DELETE)
|
||||||
|
- provenance
|
||||||
|
|
||||||
|
**NICHT loggen:**
|
||||||
|
- Credentials
|
||||||
|
- Authorization Header
|
||||||
|
- Private Key
|
||||||
|
- sensitive Approval internals, falls unnötig
|
||||||
|
|
||||||
|
**Audit muss unterscheiden:**
|
||||||
|
- **REQUESTED** (RQ hat Job erzeugt)
|
||||||
|
- **AUTHORIZED** (Executor hat Job + Approval validiert)
|
||||||
|
- **EXECUTED** (Mutation ausgeführt)
|
||||||
|
|
||||||
|
**WICHTIG: RQ REQUEST != HUMAN AUTHORIZATION.** Ein REQUESTED-Eintrag ist keine
|
||||||
|
AUTHORIZATION. Nur AUTHORIZED (nach AUTH.3D-Validierung) ist eine Autorisierung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. ISOLATED IMPLEMENTATION (Phase 20) — Design
|
||||||
|
|
||||||
|
> Implementiert in Code + Tests. KEIN produktiver Container. Modulare Komponenten.
|
||||||
|
|
||||||
|
### Module
|
||||||
|
- `job_schema.py` — Job-Schema-Validierung (Enum, typed, Allowlist, REJECTED)
|
||||||
|
- `job_store.py` — SQLite Inbox (getrennte DBs, atomarer Claim, Lease)
|
||||||
|
- `job_state_machine.py` — deterministische State Machine (FAIL CLOSED)
|
||||||
|
- `job_claim.py` — atomarer Claim/Lease/Concurrency
|
||||||
|
- `save_executor_core.py` — SAVE-Executor-Logik (SAVE-only, Content-Rekonstruktion)
|
||||||
|
- `delete_executor_core.py` — DELETE-Executor-Logik (DELETE-only, AUTH.3D-Validierung)
|
||||||
|
|
||||||
|
### Wiederverwendung
|
||||||
|
- Bestehende C5-Module (rq_c5a, rq_c5c, rq_c5_delete, approval_*) werden wiederverwendet,
|
||||||
|
nicht neu geschrieben.
|
||||||
|
- Keine Big-Bang-Neuschreibung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. TEST CONTRACT (Phase 21) — Design
|
||||||
|
|
||||||
|
> T1–T40 (siehe Mission §21). Implementiert in isolierten Tests (synthetische Secrets,
|
||||||
|
> Fake-Tolaria, Temp-DB). Keine produktiven Mutationsprobes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. ADVERSARIAL TESTS (Phase 22) — Design
|
||||||
|
|
||||||
|
> JSON duplicate keys, Unicode path ambiguity, encoded traversal, job_type casing,
|
||||||
|
> enum confusion, integer/string confusion, oversized payload, stale mission, stale
|
||||||
|
> provenance, race claim, replay after restart, DB row manipulation, cross-executor
|
||||||
|
> DB injection, approval substitution, object substitution after claim, path
|
||||||
|
> substitution after claim, state mutation after approval, crash before HTTP, crash
|
||||||
|
> after uncertain HTTP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. TEST SENSITIVITY (Phase 23) — Design
|
||||||
|
|
||||||
|
> Mutationen A–L (siehe Mission §23). Jede Mutation muss relevante Tests ROT machen.
|
||||||
|
> Falls nicht: Tests verstärken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. REGRESSION (Phase 24) — Design
|
||||||
|
|
||||||
|
> Volle relevante C5-Test-Suite + AUTH.3A-Tests + AUTH.3D-Tests. Keine bestehenden
|
||||||
|
> Sicherheitsinvarianten abschwächen. Baseline-Fails separat klassifizieren. Keine
|
||||||
|
> unrelated Reparaturen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 24. SECURITY REVIEW (Phase 25) — Design
|
||||||
|
|
||||||
|
> Explizit beweisen: RQ_CREDENTIAL_FREE, HERMES_CREDENTIAL_FREE, RAIN_CREDENTIAL_FREE,
|
||||||
|
> SAVE_EXECUTOR_SAVE_ONLY, DELETE_EXECUTOR_DELETE_ONLY, DELETE_EXECUTOR_VERIFY_ONLY,
|
||||||
|
> NO_PRIVATE_SIGNING_KEY_IN_STACK, NO_GENERIC_COMMAND_DISPATCH, NO_ARBITRARY_URL,
|
||||||
|
> NO_ARBITRARY_HTTP_METHOD, NO_ARBITRARY_HEADERS, NO_SHELL_EXECUTION,
|
||||||
|
> JOB_DOES_NOT_EQUAL_AUTHORIZATION, AUTH3D_REQUIRED_FOR_DELETE, ATOMIC_CLAIM,
|
||||||
|
> IDEMPOTENCY, REPLAY_PROTECTION, OUTCOME_UNKNOWN_FAIL_CLOSED.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 25. DESIGN DOC RECONCILIATION (Phase 26) — Design
|
||||||
|
|
||||||
|
> AUTH3B_DESIGN.md, AUTH3C_DESIGN.md, AUTH3D_DESIGN.md (untracked). Entscheidungsmatrix
|
||||||
|
> (FILE, SECURITY_RELEVANCE, UNIQUE_INFORMATION, ALREADY_REPRESENTED_IN_COMMITTED_CODE/DOCS,
|
||||||
|
> STALE_INFORMATION, RECOMMENDED_SOT_ACTION; Klassifikation COMMIT/MERGE_INTO_ARCH_DOC/
|
||||||
|
> ARCHIVE/DISCARD/KEEP_UNTRACKED_TEMPORARILY). Nur Empfehlung. Keine Mutation ohne
|
||||||
|
> separate Freigabe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 26. COMMIT / PUSH (Phase 27) — Design
|
||||||
|
|
||||||
|
> Nur AUTH.3E-Code + Tests + ausdrücklich notwendige AUTH.3E-Dokumentation. Vor Commit:
|
||||||
|
> git status, git diff, Secret Scan, Private-Key Scan, Credential Pattern Scan. Keine
|
||||||
|
> echten Credentials. Nur synthetische Testwerte. FF-only Push. HEAD == origin/main
|
||||||
|
> verifizieren. Falls Hooks scheitern: Ursache analysieren. --no-verify NICHT
|
||||||
|
> automatisch verwenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 27. FRESH CHECKER (Phase 28) — Design
|
||||||
|
|
||||||
|
> Unabhängiger Checker (32 Punkte, siehe Mission §28).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 28. FINAL REPORT (Phase 29) + HARD STOP (Phase 30)
|
||||||
|
|
||||||
|
> Nach FINAL REPORT sofort STOP. Keine produktiven Mutationen, keine Container,
|
||||||
|
> keine echten Credentials, keine Hermes-Autonomie.
|
||||||
Loading…
Reference in a new issue