trading-system-docs/tolaria/c5-sync-service/job_schema.py
Red Queen 10761f52b2 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.
2026-08-27 10:03:32 +00:00

290 lines
10 KiB
Python

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