From a11c1bbe53c05a6f2c9d6de214ad27dbc65350b0 Mon Sep 17 00:00:00 2001 From: Red Queen Date: Thu, 27 Aug 2026 08:42:26 +0000 Subject: [PATCH] AUTH.3A: C5 Caller Auth Integration (SAVE/DELETE Credential, fail-closed, Tests, Sensitivity, Gap-Doku) - rq_c5c.py: TolariaClient save_token/delete_token DI, _require_token fail-closed, write()/delete() senden Bearer (SAVE/DELETE), read()/list() ohne Credential - rq_c5_cli.py: _delete_executor liest nur DELETE-Credential (Least Privilege) - test_c5c.py: write()-Tests injizieren synthetisches SAVE-Token - test_c5_auth3a.py: isolierte AUTH.3A-Testsuite (19 Tests, Fake/Mock Tolaria) - auth3a-sensitivity.sh: 8 Sensitivitaets-Mutationen (A-H) -> ROT - AUTH3A_HUMAN_APPROVAL_AUTHENTICITY_GAP.md: Gap dokumentiert (OPEN, nicht repariert) Keine echten Tokens. Keine ENV-Mutation. Kein Deployment. Keine produktive Auth-Aktivierung. --- tolaria/c5-sync-service/auth3a-sensitivity.sh | 133 ++++++ tolaria/c5-sync-service/rq_c5_cli.py | 13 +- tolaria/c5-sync-service/rq_c5c.py | 72 ++- tolaria/c5-sync-service/test_c5_auth3a.py | 444 ++++++++++++++++++ tolaria/c5-sync-service/test_c5c.py | 27 +- .../AUTH3A_HUMAN_APPROVAL_AUTHENTICITY_GAP.md | 62 +++ 6 files changed, 731 insertions(+), 20 deletions(-) create mode 100644 tolaria/c5-sync-service/auth3a-sensitivity.sh create mode 100644 tolaria/c5-sync-service/test_c5_auth3a.py create mode 100644 tolaria/tolaria-write-auth/AUTH3A_HUMAN_APPROVAL_AUTHENTICITY_GAP.md diff --git a/tolaria/c5-sync-service/auth3a-sensitivity.sh b/tolaria/c5-sync-service/auth3a-sensitivity.sh new file mode 100644 index 0000000..3d17b2a --- /dev/null +++ b/tolaria/c5-sync-service/auth3a-sensitivity.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# AUTH.3A PHASE 11 — Test-Sensitivity: Beweise ROT bei gebrochener Auth-Semantik. +# +# Mutiert rq_c5c.py temporaer (A-H) und prueft, dass die AUTH.3A-Testsuite +# ROT wird. Keine hardcodierten PASS-Werte. Keine Produktion. +# +# A. fehlendes Token sendet Request trotzdem +# B. Authorization Header entfernt +# C. SAVE Caller laedt DELETE Token +# D. DeleteExecutor laedt SAVE Token +# E. Delete ohne Human Approval erlaubt +# F. Token wird geloggt +# G. Retry verliert Authorization +# H. Replay faellt auf unauthenticated zurueck +set -u +cd "$(dirname "$0")" +TARGET="rq_c5c.py" +TARGET2="rq_c5_delete.py" +TEST="test_c5_auth3a.py" +PY="python3 -m unittest ${TEST%.py}" + +fail=0 +run_mutation() { + local name="$1"; shift + local expect_red="$1"; shift + # Mutation anwenden + "$@" || { echo "MUTATION-APPLY-FAIL: $name"; fail=1; return; } + # Tests ausfuehren + local out + out=$($PY 2>&1) + local rc=$? + # Mutation zurueckrollen (aus .bak, NICHT git checkout — AUTH.3A-Aenderungen sind uncommitted) + cp "$TARGET.bak" "$TARGET" + cp "$TARGET2.bak" "$TARGET2" + if [ "$expect_red" = "RED" ]; then + if [ $rc -ne 0 ]; then + echo "OK [$name] -> ROT (rc=$rc)" + else + echo "FAIL[$name] -> GRUEN (sollte ROT sein)" + fail=1 + fi + else + if [ $rc -eq 0 ]; then + echo "OK [$name] -> GRUEN" + else + echo "FAIL[$name] -> ROT (sollte GRUEN sein)" + fail=1 + fi + fi +} + +cp "$TARGET" "$TARGET.bak" +cp "$TARGET2" "$TARGET2.bak" + +# A. fehlendes Token sendet Request trotzdem (entferne _require_token in write) +run_mutation "A-fehlendes-token-sendet-trotzdem" RED \ + python3 - <<'EOF' +import re +p="rq_c5c.py" +s=open(p).read() +# write(): _require_token entfernen -> sendet ohne Token +s=s.replace("token = self._require_token(self.save_token, \"SAVE\")","token = self.save_token") +open(p,"w").write(s) +EOF + +# B. Authorization Header entfernt (kein auth_token an _post) +run_mutation "B-auth-header-entfernt" RED \ + python3 - <<'EOF' +p="rq_c5c.py" +s=open(p).read() +s=s.replace("if auth_token is not None:\n headers[\"Authorization\"] = f\"Bearer {auth_token}\"","pass") +open(p,"w").write(s) +EOF + +# C. SAVE Caller laedt DELETE Token (write nutzt delete_token) +run_mutation "C-save-laedt-delete-token" RED \ + python3 - <<'EOF' +p="rq_c5c.py" +s=open(p).read() +s=s.replace("token = self._require_token(self.save_token, \"SAVE\")","token = self._require_token(self.delete_token, \"SAVE\")") +open(p,"w").write(s) +EOF + +# D. DeleteExecutor laedt SAVE Token (delete nutzt save_token) +run_mutation "D-delete-laedt-save-token" RED \ + python3 - <<'EOF' +p="rq_c5c.py" +s=open(p).read() +s=s.replace("token = self._require_token(self.delete_token, \"DELETE\")","token = self._require_token(self.save_token, \"DELETE\")") +open(p,"w").write(s) +EOF + +# E. Delete ohne Human Approval erlaubt (entferne _validate_approval in execute) +run_mutation "E-delete-ohne-approval-erlaubt" RED \ + python3 - <<'EOF' +p="rq_c5_delete.py" +s=open(p).read() +s=s.replace("approval = self._validate_approval(commit_sha, change)","approval = {'approval_id': 'x', 'workflow_commit': commit_sha, 'object_id': change.get('object_id'), 'path': change.get('path_before') or change.get('path_after')}") +open(p,"w").write(s) +EOF + +# F. Token wird geloggt (repr enthaelt Token) +run_mutation "F-token-geloggt" RED \ + python3 - <<'EOF' +p="rq_c5c.py" +s=open(p).read() +# Fuege eine __repr__ ein, die den save_token exponiert (nach self.delete_token = delete_token) +s=s.replace(" self.save_token = save_token\n self.delete_token = delete_token", + " self.save_token = save_token\n self.delete_token = delete_token\n\n def __repr__(self):\n return f\"TolariaClient(save_token={self.save_token})\"") +open(p,"w").write(s) +EOF + +# G. Retry verliert Authorization (write sendet kein Token beim 2. Aufruf) +run_mutation "G-retry-verliert-auth" RED \ + python3 - <<'EOF' +p="rq_c5c.py" +s=open(p).read() +s=s.replace("token = self._require_token(self.save_token, \"SAVE\")","token = self._require_token(self.save_token, \"SAVE\") if not hasattr(self,'_retried') else None\n self._retried = True") +open(p,"w").write(s) +EOF + +# H. Replay faellt auf unauthenticated zurueck (delete ohne Token) +run_mutation "H-replay-unauthenticated" RED \ + python3 - <<'EOF' +p="rq_c5c.py" +s=open(p).read() +s=s.replace("token = self._require_token(self.delete_token, \"DELETE\")","token = None") +open(p,"w").write(s) +EOF + +rm -f "$TARGET.bak" "$TARGET2.bak" +echo "=== SENSITIVITY RESULT: $([ $fail -eq 0 ] && echo ALL-RED || echo FAILURES) ===" +exit $fail diff --git a/tolaria/c5-sync-service/rq_c5_cli.py b/tolaria/c5-sync-service/rq_c5_cli.py index 01bf15b..3a2c200 100644 --- a/tolaria/c5-sync-service/rq_c5_cli.py +++ b/tolaria/c5-sync-service/rq_c5_cli.py @@ -51,6 +51,8 @@ from rq_c5c import ( assert_no_master_write, DEFAULT_TOLARIA_BASE, ENV_TOLARIA_BASE, + ENV_TOLARIA_SAVE_TOKEN, + ENV_TOLARIA_DELETE_TOKEN, ) from rq_c5d import ( C5DEngine, @@ -341,7 +343,12 @@ def cmd_c5e_guarantees(args: argparse.Namespace) -> int: def _delete_executor(args: argparse.Namespace) -> DeleteExecutor: store = _store(args.db) - client = TolariaClient(base_url=args.tolaria_base) + # AUTH.3A: DELETE-Caller liest NUR das DELETE-Credential aus der + # Runtime-Konfiguration (Least Privilege, AUTH.1 §3). Kein SAVE-Token. + client = TolariaClient( + base_url=args.tolaria_base, + delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN), + ) return DeleteExecutor(store, client) @@ -417,7 +424,7 @@ def cmd_c5_delete_approve(args: argparse.Namespace) -> int: def cmd_c5_delete_execute(args: argparse.Namespace) -> int: """Fuehrt den kontrollierten Tolaria-DELETE aus (verlangt persistierte Approval).""" store = _store(args.db) - executor = DeleteExecutor(store, TolariaClient(base_url=args.tolaria_base)) + executor = _delete_executor(args) try: result = executor.execute(args.commit) print(json.dumps(result, ensure_ascii=False, indent=2)) @@ -451,7 +458,7 @@ def cmd_c5_delete_status(args: argparse.Namespace) -> int: def cmd_c5_delete_replay(args: argparse.Namespace) -> int: """Idempotenter Replay nach Crash (verlangt persistierte Approval).""" store = _store(args.db) - executor = DeleteExecutor(store, TolariaClient(base_url=args.tolaria_base)) + executor = _delete_executor(args) try: result = executor.replay(args.commit) print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/tolaria/c5-sync-service/rq_c5c.py b/tolaria/c5-sync-service/rq_c5c.py index 5f2bf97..b40da93 100644 --- a/tolaria/c5-sync-service/rq_c5c.py +++ b/tolaria/c5-sync-service/rq_c5c.py @@ -91,6 +91,15 @@ from rq_c5b import ( DEFAULT_TOLARIA_BASE = "http://187.124.31.123:5173/api/vault" ENV_TOLARIA_BASE = "C5_TOLARIA_BASE" +# AUTH.3A ENV-Contract (PHASE 3): Eindeutige Namen, festgelegt in AUTH.2 +# (vite.config.ts: process.env.TOLARIA_SAVE_TOKEN / TOLARIA_DELETE_TOKEN). +# AUTH.3A implementiert NUR die code-seitige Faehigkeit, diese aus der +# Runtime-Konfiguration zu lesen. Es werden KEINE echten ENV-Werte gesetzt. +# Least Privilege: SAVE-Caller liest NUR TOLARIA_SAVE_TOKEN, DELETE-Caller +# liest NUR TOLARIA_DELETE_TOKEN. Kein Prozess laedt beide automatisch. +ENV_TOLARIA_SAVE_TOKEN = "TOLARIA_SAVE_TOKEN" +ENV_TOLARIA_DELETE_TOKEN = "TOLARIA_DELETE_TOKEN" + # Vault-Pfad-Praefix (verbindlich — ohne Praefix liest /content das Tolaria-eigene # README statt des Vault-Objekts -> false drift) VAULT_PREFIX = "/app/vault" @@ -207,18 +216,42 @@ class TolariaClient: Keine generische Agent-Write-Funktion. Kein Search-Rebuild-Aufruf. """ - def __init__(self, base_url: Optional[str] = None, timeout: float = 15.0): + def __init__( + self, + base_url: Optional[str] = None, + timeout: float = 15.0, + save_token: Optional[str] = None, + delete_token: Optional[str] = None, + ): self.base_url = (base_url or os.environ.get(ENV_TOLARIA_BASE) or DEFAULT_TOLARIA_BASE).rstrip("/") self.timeout = timeout + # AUTH.3A: Explizite Credential-Injection (kein verstecktes globales + # Credential, kein Default-Token). SAVE- und DELETE-Credential sind + # strikt getrennt (AUTH.1 §3). Fehlende/leere Werte -> fail-closed + # beim jeweiligen mutierenden Aufruf (AUTH.1 §4). + self.save_token = save_token + self.delete_token = delete_token # -- HTTP-Helfer -------------------------------------------------------- - def _post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]: + def _post( + self, + endpoint: str, + payload: Dict[str, Any], + auth_token: Optional[str] = None, + ) -> Dict[str, Any]: url = f"{self.base_url}/{endpoint.lstrip('/')}" data = json.dumps(payload).encode("utf-8") + headers: Dict[str, str] = {"Content-Type": "application/json"} + # AUTH.3A: Authorization-Header NUR wenn ein Token explizit uebergeben + # wird (mutierende SAVE/DELETE-Aufrufe). READ-Aufrufe senden KEIN + # Credential (Least Privilege, AUTH.1 §2). Der Token-Wert wird nie + # geloggt (AUTH.1 §6). + if auth_token is not None: + headers["Authorization"] = f"Bearer {auth_token}" req = urllib.request.Request( - url, data=data, headers={"Content-Type": "application/json"}, + url, data=data, headers=headers, method="POST", ) try: @@ -264,9 +297,31 @@ class TolariaClient: # -- Write (NUR C5C-Writer) -------------------------------------------- + def _require_token(self, token: Optional[str], scope: str) -> str: + """Fail-closed: fehlendes/leeres/malformed Credential -> kein HTTP. + + AUTH.1 §4: Ein mutierender Request darf NIE ohne gültiges Credential + gesendet werden. Fehlendes oder leeres Token -> lokaler Abbruch + (TolariaWriteError, RC_AUTH_FAILURE), HTTP wird NICHT aufgerufen. + """ + if not token or not isinstance(token, str) or not token.strip(): + raise TolariaWriteError( + f"Tolaria {scope}-Credential fehlt oder ist leer " + f"(fail-closed, kein Request gesendet)", + RC_AUTH_FAILURE, + ) + return token + def write(self, vault_path: str, content: str) -> Dict[str, Any]: - """Schreibt ein Vault-Objekt (POST /save). Isolierte Write-Komponente.""" - resp = self._post("save", {"path": vault_path, "content": content}) + """Schreibt ein Vault-Objekt (POST /save). Isolierte Write-Komponente. + + AUTH.3A: SAVE-Scope. Sendet Authorization: Bearer . + Fail-closed: fehlendes/leeres SAVE-Credential -> lokaler Abbruch, + HTTP wird NICHT aufgerufen (AUTH.1 §4). + """ + token = self._require_token(self.save_token, "SAVE") + resp = self._post("save", {"path": vault_path, "content": content}, + auth_token=token) # HTTP 2xx + JSON null / leerer Payload -> erfolgreicher Transport. # Nur hier (write /save) wird dieser Fall als Erfolg gewertet; der # nachgelagerte Read-Back (verify) bleibt zwingend für einen C5C-Step. @@ -289,8 +344,13 @@ class TolariaClient: Endpoint-/Payload-Contract (POST /delete mit {"path": ...}) ist aus dem vorhandenen Tolaria-Code/Router und der dokumentierten Incident-Evidence bestimmt. KEINE produktive Endpoint-Probe (Incident-Regel). + + AUTH.3A: DELETE-Scope. Sendet Authorization: Bearer . + Fail-closed: fehlendes/leeres DELETE-Credential -> lokaler Abbruch, + HTTP wird NICHT aufgerufen (AUTH.1 §4). """ - resp = self._post("delete", {"path": vault_path}) + token = self._require_token(self.delete_token, "DELETE") + resp = self._post("delete", {"path": vault_path}, auth_token=token) if resp is None: resp = {} if "error" in resp: diff --git a/tolaria/c5-sync-service/test_c5_auth3a.py b/tolaria/c5-sync-service/test_c5_auth3a.py new file mode 100644 index 0000000..aec75c2 --- /dev/null +++ b/tolaria/c5-sync-service/test_c5_auth3a.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +""" +Red Queen — AUTH.3A C5 Caller Auth Integration Testsuite. + +Testet die clientseitige Auth-Unterstuetzung der legitimen C5-Tolaria-Writer +gegen ein Fake/Mock-Tolaria (KEINE Produktion, KEINE echten Tokens). + +Abgedeckte Faelle (AUTH.3A PHASE 9): + SAVE CALLER (TolariaClient.write -> /save, SAVE-Scope) + T1 SAVE mit SAVE token -> Authorization korrekt + T2 SAVE ohne token -> lokal DENIED -> HTTP niemals aufgerufen + T3 SAVE leeres token -> lokal DENIED + T4 READ ohne SAVE token -> funktioniert + T5 SAVE token wird bei READ nicht gesendet + DELETE CALLER (TolariaClient.delete -> /delete, DELETE-Scope) + T6 DELETE mit Approval + DELETE token -> Request erlaubt + T7 DELETE mit Approval aber ohne token -> lokal DENIED + T8 DELETE mit token aber ohne Approval -> DENIED + T9 DELETE ohne Approval und ohne token -> DENIED + T10 DELETE token nicht bei SAVE nutzbar + T11 SAVE token nicht bei DELETE nutzbar + LOGGING + T12 Token nicht in Logs + T13 Token nicht in Exceptions + T14 Token nicht in Evidence + RETRY/REPLAY + T15 Retry behaelt korrekten Scope + T16 Replay erzeugt keinen unauthentifizierten Write + T17 fehlendes Credential bei Recovery -> fail-closed + +AUTH.3A PHASE 10 (Wrong-Scope Adversarial): + T18 SAVE credential beim DeleteExecutor -> scheitert + T19 DELETE credential beim Save-Caller -> scheitert + +Nur synthetische Test-Tokens. KEINE echten Secrets. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from unittest import mock +from typing import Any, Dict, List, Optional + +# C5-Module importieren (aus demselben Verzeichnis) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from rq_c5c import ( + TolariaClient, + TolariaWriteError, + RC_AUTH_FAILURE, + ENV_TOLARIA_SAVE_TOKEN, + ENV_TOLARIA_DELETE_TOKEN, +) +from rq_c5_delete import DeleteExecutor, DeleteApprovalError, DeleteExecutionError +from rq_c5a import ( + C5AStore, + ST_DELETE_APPROVED, + ST_UPDATING_SEARCH, + OP_DELETE_REQUEST, +) + +# Synthetische Test-Tokens (AUTH.1 §6: nur synthetische Werte, keine echten) +SAVE_TOKEN = "auth3a-save-synthetic-token-0001" +DELETE_TOKEN = "auth3a-delete-synthetic-token-0002" + + +class FakeTolaria: + """In-memory Fake-Tolaria (kein Netzwerk, keine Produktion).""" + + def __init__(self) -> None: + self.objects: Dict[str, str] = {} + self.calls: List[Dict[str, Any]] = [] + + def read(self, vault_path: str) -> Optional[str]: + self.calls.append({"op": "read", "path": vault_path}) + return self.objects.get(vault_path) + + def write(self, vault_path: str, content: str) -> Dict[str, Any]: + self.calls.append({"op": "write", "path": vault_path}) + self.objects[vault_path] = content + return {} + + def delete(self, vault_path: str) -> Dict[str, Any]: + self.calls.append({"op": "delete", "path": vault_path}) + self.objects.pop(vault_path, None) + return {} + + def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: + self.calls.append({"op": "list", "path": vault_path}) + return [{"path": p} for p in self.objects] + + +class FakeTolariaClient(TolariaClient): + """TolariaClient, der gegen FakeTolaria statt HTTP arbeitet. + + Ueberschreibt write()/delete()/read()/list() -> _require_token wird in + den ueberschriebenen Methoden NICHT aufgerufen. Fuer die Auth-Tests + (T1-T17) wird stattdessen der ECHTE TolariaClient mit gemocktem _post + verwendet, um den Authorization-Header zu verifizieren. + """ + + def __init__(self, fake: FakeTolaria, **kwargs: Any): + super().__init__(base_url="http://fake", **kwargs) + self.fake = fake + + def read(self, vault_path: str) -> Optional[str]: + return self.fake.read(vault_path) + + def write(self, vault_path: str, content: str) -> Dict[str, Any]: + return self.fake.write(vault_path, content) + + def delete(self, vault_path: str) -> Dict[str, Any]: + return self.fake.delete(vault_path) + + def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: + return self.fake.list(vault_path) + + +# --------------------------------------------------------------------------- +# Test-Helfer: Echter TolariaClient mit gemocktem _post (Auth-Header pruefen) +# --------------------------------------------------------------------------- + +def _capture_post(client: TolariaClient) -> List[Dict[str, Any]]: + """Mockt urllib.request.urlopen global und zeichnet (endpoint, auth_header) auf. + + Testet den ECHTEN _post-Pfad inkl. Header-Bau, damit Mutationen am + Authorization-Header (z. B. Header entfernt) erkannt werden. Der Mock + bleibt aktiv, bis die Tests die Aufrufe gemacht haben. + """ + captured: List[Dict[str, Any]] = [] + + class FakeResp: + def __init__(self, body: bytes = b"{}"): + self._body = body + def read(self) -> bytes: + return self._body + def __enter__(self): + return self + def __exit__(self, *a): + return False + + def fake_urlopen(req: Any, timeout: float = 15.0) -> FakeResp: + url = req.full_url + endpoint = url.rsplit("/", 1)[-1] + headers = dict(req.headers) + captured.append({ + "endpoint": endpoint, + "auth_header": headers.get("Authorization"), + }) + return FakeResp() + + patcher = mock.patch("urllib.request.urlopen", side_effect=fake_urlopen) + patcher.start() + # Mock bleibt aktiv; Tests rufen danach client.write()/delete()/read() auf. + return captured + + +def _make_store() -> C5AStore: + db = os.path.join(tempfile.mkdtemp(prefix="auth3a_db_"), "c5a.db") + return C5AStore(db) + + +def _seed_delete_approved(store: C5AStore, sha: str, oid: str, path: str) -> int: + """Legt einen DELETE_APPROVED-Commit + Approval an (fuer T6-T9). + + Gibt die echte object_change_id (DB-Zeilen-ID) zurueck. + """ + store.upsert_commit({ + "commit_sha": sha, "parent_sha": None, "sequence": 1, + "status": ST_DELETE_APPROVED, "retry_count": 0, + }) + change = store.add_object_change({ + "commit_sha": sha, "object_id": oid, + "operation": OP_DELETE_REQUEST, "path_before": path, "path_after": None, + }) + assert change is not None + change_id = int(change["id"]) + store.create_delete_approval( + sha, change_id, oid, path, "human:christian", + approval_id="appr-1", + ) + return change_id + + +# --------------------------------------------------------------------------- +# SAVE CALLER Tests (T1-T5) +# --------------------------------------------------------------------------- + +class TestSaveCallerAuth(unittest.TestCase): + """AUTH.3A PHASE 9: SAVE-Caller (TolariaClient.write -> /save).""" + + def test_t1_save_with_token_sends_authorization(self): + """T1: SAVE mit SAVE token -> Authorization korrekt.""" + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) + captured = _capture_post(client) + client.write("/app/vault/x.md", "content") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0]["endpoint"], "save") + self.assertEqual(captured[0]["auth_header"], f"Bearer {SAVE_TOKEN}") + + def test_t2_save_without_token_fails_closed_no_http(self): + """T2: SAVE ohne token -> lokal DENIED -> HTTP niemals aufgerufen.""" + client = TolariaClient(base_url="http://fake") # kein save_token + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.write("/app/vault/x.md", "content") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + # HTTP (via urlopen) wurde NICHT aufgerufen + self.assertEqual(len(captured), 0) + + def test_t3_save_empty_token_fails_closed(self): + """T3: SAVE leeres token -> lokal DENIED.""" + client = TolariaClient(base_url="http://fake", save_token="") + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.write("/app/vault/x.md", "content") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + self.assertEqual(len(captured), 0) + + def test_t4_read_without_save_token_works(self): + """T4: READ ohne SAVE token -> funktioniert.""" + client = TolariaClient(base_url="http://fake") # kein save_token + captured = _capture_post(client) + # read() -> _post("content", ...) ohne auth_token + client.read("/app/vault/x.md") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0]["endpoint"], "content") + self.assertIsNone(captured[0]["auth_header"]) + + def test_t5_save_token_not_sent_on_read(self): + """T5: SAVE token wird bei READ nicht gesendet (Least Privilege).""" + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) + captured = _capture_post(client) + client.read("/app/vault/x.md") + client.list("/app/vault") + # Weder content noch list senden ein Credential + for c in captured: + self.assertIsNone(c["auth_header"]) + + +# --------------------------------------------------------------------------- +# DELETE CALLER Tests (T6-T11) +# --------------------------------------------------------------------------- + +class TestDeleteCallerAuth(unittest.TestCase): + """AUTH.3A PHASE 9: DELETE-Caller (TolariaClient.delete -> /delete).""" + + def test_t6_delete_with_approval_and_token_allowed(self): + """T6: DELETE mit Approval + DELETE token -> Request erlaubt. + + Testet die Client-Ebene: delete() mit delete_token sendet den + Authorization-Header. (Die Approval-Gates des Executors werden in + T8/T9 separat getestet; hier geht es um die Credential-Integration.) + """ + client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) + captured = _capture_post(client) + result = client.delete("/app/vault/notes/x.md") + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0]["endpoint"], "delete") + self.assertEqual(captured[0]["auth_header"], f"Bearer {DELETE_TOKEN}") + + def test_t7_delete_with_approval_but_no_token_fails_closed(self): + """T7: DELETE mit Approval aber ohne token -> lokal DENIED.""" + client = TolariaClient(base_url="http://fake") # kein delete_token + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.delete("/app/vault/notes/x.md") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + self.assertEqual(len(captured), 0) + + def test_t8_delete_with_token_but_no_approval_denied(self): + """T8: DELETE mit token aber ohne Approval -> DENIED (Human Gate).""" + store = _make_store() + # Kein Approval angelegt -> DeleteExecutor.execute() muss fail-closed + store.upsert_commit({ + "commit_sha": "sha2", "parent_sha": None, "sequence": 1, + "status": ST_DELETE_APPROVED, "retry_count": 0, + }) + store.add_object_change({ + "commit_sha": "sha2", "id": "chg-2", "object_id": "object/2", + "operation": OP_DELETE_REQUEST, "path_before": "notes/y.md", + "path_after": None, + }) + client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) + executor = DeleteExecutor(store, client) + with self.assertRaises(DeleteApprovalError): + executor.execute("sha2") + store.close() + + def test_t9_delete_no_approval_no_token_denied(self): + """T9: DELETE ohne Approval und ohne token -> DENIED.""" + store = _make_store() + store.upsert_commit({ + "commit_sha": "sha3", "parent_sha": None, "sequence": 1, + "status": ST_DELETE_APPROVED, "retry_count": 0, + }) + store.add_object_change({ + "commit_sha": "sha3", "id": "chg-3", "object_id": "object/3", + "operation": OP_DELETE_REQUEST, "path_before": "notes/z.md", + "path_after": None, + }) + client = TolariaClient(base_url="http://fake") # kein delete_token + executor = DeleteExecutor(store, client) + with self.assertRaises(DeleteApprovalError): + executor.execute("sha3") + store.close() + + def test_t10_delete_token_not_usable_for_save(self): + """T10: DELETE token nicht bei SAVE nutzbar (Scope-Separation).""" + # Client mit NUR delete_token -> write() (SAVE) muss fail-closed + client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.write("/app/vault/x.md", "content") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + self.assertEqual(len(captured), 0) + + def test_t11_save_token_not_usable_for_delete(self): + """T11: SAVE token nicht bei DELETE nutzbar (Scope-Separation).""" + # Client mit NUR save_token -> delete() (DELETE) muss fail-closed + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.delete("/app/vault/x.md") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + self.assertEqual(len(captured), 0) + + +# --------------------------------------------------------------------------- +# LOGGING Tests (T12-T14) +# --------------------------------------------------------------------------- + +class TestLoggingRedaction(unittest.TestCase): + """AUTH.3A PHASE 8/9: Token nicht in Logs/Exceptions/Evidence.""" + + def test_t12_token_not_in_logs(self): + """T12: Token nicht in Logs (kein repr/str enthaelt den Wert).""" + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN, + delete_token=DELETE_TOKEN) + # repr/str des Clients darf keinen Token-Wert enthalten + self.assertNotIn(SAVE_TOKEN, repr(client)) + self.assertNotIn(DELETE_TOKEN, repr(client)) + self.assertNotIn(SAVE_TOKEN, str(client)) + self.assertNotIn(DELETE_TOKEN, str(client)) + + def test_t13_token_not_in_exceptions(self): + """T13: Token nicht in Exceptions (fail-closed Fehler).""" + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) + # Fehler bei fehlendem Credential (delete ohne delete_token) + try: + client.delete("/app/vault/x.md") + self.fail("sollte fail-closed werfen") + except TolariaWriteError as e: + self.assertNotIn(DELETE_TOKEN, str(e)) + self.assertNotIn(SAVE_TOKEN, str(e)) + self.assertNotIn("Bearer", str(e)) + + def test_t14_token_not_in_evidence(self): + """T14: Token nicht in Evidence (to_dict / Fehler-Dict).""" + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) + try: + client.delete("/app/vault/x.md") + self.fail("sollte fail-closed werfen") + except TolariaWriteError as e: + d = e.to_dict() + self.assertNotIn(SAVE_TOKEN, json.dumps(d)) + self.assertNotIn(DELETE_TOKEN, json.dumps(d)) + + +# --------------------------------------------------------------------------- +# RETRY/REPLAY Tests (T15-T17) +# --------------------------------------------------------------------------- + +class TestRetryReplay(unittest.TestCase): + """AUTH.3A PHASE 9: Retry/Replay behalten korrekten Scope.""" + + def test_t15_retry_keeps_correct_scope(self): + """T15: Retry behaelt korrekten Scope (SAVE-Token bleibt SAVE).""" + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) + captured = _capture_post(client) + # Zwei aufeinanderfolgende SAVE-Aufrufe (Retry-Szenario) + client.write("/app/vault/a.md", "a") + client.write("/app/vault/b.md", "b") + self.assertEqual(len(captured), 2) + for c in captured: + self.assertEqual(c["endpoint"], "save") + self.assertEqual(c["auth_header"], f"Bearer {SAVE_TOKEN}") + + def test_t16_replay_no_unauthenticated_write(self): + """T16: Replay erzeugt keinen unauthentifizierten Write.""" + # Client ohne save_token -> write() fail-closed (kein unauthentifizierter Write) + client = TolariaClient(base_url="http://fake") + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError): + client.write("/app/vault/x.md", "content") + self.assertEqual(len(captured), 0) + + def test_t17_missing_credential_recovery_fails_closed(self): + """T17: fehlendes Credential bei Recovery -> fail-closed.""" + # Recovery = erneuter Write nach Crash. Ohne Credential -> fail-closed. + client = TolariaClient(base_url="http://fake") + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.write("/app/vault/recover.md", "content") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + self.assertEqual(len(captured), 0) + + +# --------------------------------------------------------------------------- +# Wrong-Scope Adversarial (PHASE 10) +# --------------------------------------------------------------------------- + +class TestWrongScopeAdversarial(unittest.TestCase): + """AUTH.3A PHASE 10: Wrong-Scope-Credentials muessen scheitern.""" + + def test_t18_save_credential_at_delete_executor_fails(self): + """T18: SAVE credential beim DeleteExecutor -> scheitert.""" + # DeleteExecutor bekommt einen Client mit NUR save_token. + # delete() (DELETE-Scope) muss fail-closed (kein DELETE-Token). + client = TolariaClient(base_url="http://fake", save_token=SAVE_TOKEN) + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.delete("/app/vault/x.md") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + self.assertEqual(len(captured), 0) + + def test_t19_delete_credential_at_save_caller_fails(self): + """T19: DELETE credential beim Save-Caller -> scheitert.""" + # Save-Caller bekommt einen Client mit NUR delete_token. + # write() (SAVE-Scope) muss fail-closed (kein SAVE-Token). + client = TolariaClient(base_url="http://fake", delete_token=DELETE_TOKEN) + captured = _capture_post(client) + with self.assertRaises(TolariaWriteError) as ctx: + client.write("/app/vault/x.md", "content") + self.assertEqual(ctx.exception.reason_code, RC_AUTH_FAILURE) + self.assertEqual(len(captured), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tolaria/c5-sync-service/test_c5c.py b/tolaria/c5-sync-service/test_c5c.py index b0ec51c..468252c 100644 --- a/tolaria/c5-sync-service/test_c5c.py +++ b/tolaria/c5-sync-service/test_c5c.py @@ -962,7 +962,7 @@ class TestTolariaReadNotFoundSemantic(unittest.TestCase): def _post(self, status: int, body: str, reason_code=None): """Liefert einen Callable, der ein TolariaWriteError/JSON wirft/liefert.""" - def fake_post(endpoint, payload): + def fake_post(endpoint, payload, auth_token=None): if status == 200: return json.loads(body) raise TolariaWriteError( @@ -1002,7 +1002,7 @@ class TestTolariaReadNotFoundSemantic(unittest.TestCase): # E) HTTP 500 -> weiterhin technischer Fehler (retrybar, Unavailable) def test_e_500_is_unavailable(self): - def fake_500(endpoint, payload): + def fake_500(endpoint, payload, auth_token=None): raise TolariaUnavailableError("Tolaria HTTP 500", "TOLARIA_UNAVAILABLE") with mock.patch.object(self.client, "_post", side_effect=fake_500): with self.assertRaises(TolariaUnavailableError): @@ -1035,11 +1035,16 @@ class TestTolariaWriteNullResponseSemantic(unittest.TestCase): Read-Back bleibt zwingend; 4xx/5xx weiterhin fail-closed.""" def setUp(self): - self.client = TolariaClient(base_url="http://fake") + # AUTH.3A: write() ist fail-closed ohne SAVE-Credential. Fuer die + # /save-Semantik-Tests wird ein synthetisches SAVE-Token injiziert. + self.client = TolariaClient( + base_url="http://fake", + save_token="auth3a-save-synthetic-token-0001", + ) def _post(self, status: int, body: str, reason_code=None): """Liefert einen Callable, der ein TolariaWriteError/JSON wirft/liefert.""" - def fake_post(endpoint, payload): + def fake_post(endpoint, payload, auth_token=None): if status == 200: return json.loads(body) raise TolariaWriteError( @@ -1054,7 +1059,7 @@ class TestTolariaWriteNullResponseSemantic(unittest.TestCase): # A) /save HTTP 200 + JSON null -> write() kein Crash, Save-Step erfolgreich def test_a_save_200_null_no_crash(self): - def fake_post(endpoint, payload): + def fake_post(endpoint, payload, auth_token=None): self.assertEqual(endpoint, "save") return None # HTTP 200 + JSON null with mock.patch.object(self.client, "_post", side_effect=fake_post): @@ -1064,13 +1069,13 @@ class TestTolariaWriteNullResponseSemantic(unittest.TestCase): # B) /save HTTP 200 + {} -> weiterhin erfolgreich def test_b_save_200_empty_ok(self): - with mock.patch.object(self.client, "_post", side_effect=lambda ep, pl: {}): + with mock.patch.object(self.client, "_post", side_effect=lambda ep, pl, auth_token=None: {}): resp = self.client.write("/app/vault/x.md", self._content()) self.assertIsInstance(resp, dict) # C) /save HTTP 200 + normaler Success-Payload -> erfolgreich def test_c_save_200_payload_ok(self): - with mock.patch.object(self.client, "_post", side_effect=lambda ep, pl: {"ok": True}): + with mock.patch.object(self.client, "_post", side_effect=lambda ep, pl, auth_token=None: {"ok": True}): resp = self.client.write("/app/vault/x.md", self._content()) self.assertEqual(resp, {"ok": True}) @@ -1089,7 +1094,7 @@ class TestTolariaWriteNullResponseSemantic(unittest.TestCase): # F) /save 500 -> technischer Fehler / Retry Contract (Unavailable) def test_f_save_500_unavailable(self): - def fake_500(endpoint, payload): + def fake_500(endpoint, payload, auth_token=None): raise TolariaUnavailableError("Tolaria HTTP 500", "TOLARIA_UNAVAILABLE") with mock.patch.object(self.client, "_post", side_effect=fake_500): with self.assertRaises(TolariaUnavailableError): @@ -1100,7 +1105,7 @@ class TestTolariaWriteNullResponseSemantic(unittest.TestCase): content = self._content() calls = {"n": 0} - def fake_post(endpoint, payload): + def fake_post(endpoint, payload, auth_token=None): calls["n"] += 1 if endpoint == "save": return None # HTTP 200 + JSON null @@ -1119,7 +1124,7 @@ class TestTolariaWriteNullResponseSemantic(unittest.TestCase): content = self._content("TARGET") drifted = self._content("DRIFTED") - def fake_post(endpoint, payload): + def fake_post(endpoint, payload, auth_token=None): if endpoint == "save": return None if endpoint == "content": @@ -1137,7 +1142,7 @@ class TestTolariaWriteNullResponseSemantic(unittest.TestCase): content = self._content() n = {"n": 0} - def fake_post(endpoint, payload): + def fake_post(endpoint, payload, auth_token=None): n["n"] += 1 if endpoint == "save": return None diff --git a/tolaria/tolaria-write-auth/AUTH3A_HUMAN_APPROVAL_AUTHENTICITY_GAP.md b/tolaria/tolaria-write-auth/AUTH3A_HUMAN_APPROVAL_AUTHENTICITY_GAP.md new file mode 100644 index 0000000..a382477 --- /dev/null +++ b/tolaria/tolaria-write-auth/AUTH3A_HUMAN_APPROVAL_AUTHENTICITY_GAP.md @@ -0,0 +1,62 @@ +# AUTH.3A — HUMAN APPROVAL AUTHENTICITY GAP (Dokumentation) + +**Status:** OPEN (nicht in AUTH.3A repariert — bewusste Scope-Begrenzung) +**Mission:** AUTH.3A (C5 Caller Auth Integration) +**Datum:** 2026-08-27 + +## Gap-Beschreibung + +Der bekannte PRE_HERMES-Gap: + +- `approved_by="human:christian"` ist ein **frei waehlbares CLI-Argument** + (`rq_c5_cli.py`, `--approved-by` mit Default `"human:christian"`). +- Es gibt **keine kryptographische Bindung** der Human-Approval an Christian + (keine Signatur, kein Nonce-Beweis, kein Out-of-Band-Verifikationskanal). + +Das bedeutet: Ein Angreifer mit Schreibzugriff auf die C5-DB (oder auf den +CLI-Aufruf) koennte eine Approval mit `approved_by="human:christian"` faelschen, +ohne dass dies kryptographisch nachweisbar waere. + +## Scope-Begrenzung (AUTH.3A) + +AUTH.3A repariert diesen Gap **NICHT**. Gruende: + +1. AUTH.3A ist strikt auf **C5-Caller-Auth-Integration** begrenzt + (SAVE-/DELETE-Credential fuer die legitimen Writer). +2. Die Approval-Authenticity ist ein **eigenstaendiges Security-Thema** + (eigene Mission, eigene Freigabe durch Christian). +3. Keine Scope-Ausweitung ueber die AUTH.3A-Freigabe hinaus. + +## Was AUTH.3A sicherstellt (in Bezug auf DELETE) + +AUTH.3A fuegt dem DELETE-Pfad ein **zweites, unabhaengiges Gate** hinzu: + +- **Gate 1 (bestehend):** gueltige persistierte Human-Approval + (`DeleteExecutor._validate_approval` — Approval existiert, APPROVED, + bindet exakt Commit/Objekt/Pfad). +- **Gate 2 (AUTH.3A, neu):** gueltiges DELETE-Credential + (`TolariaClient._require_token` — fail-closed, kein Request ohne Token). + +Beide Gates sind **zwingend und unabhaengig**: + +- DELETE-Credential ersetzt NIE die Human-Approval. +- Human-Approval ersetzt NIE das DELETE-Credential. +- Fehlt eines von beiden → FAIL CLOSED (kein Request gesendet). + +## Verbleibende Luecke (nicht in AUTH.3A) + +Die **Authentizitaet** der Human-Approval selbst (dass sie wirklich von +Christian stammt) bleibt ungeloest. Das DELETE-Credential schuetzt vor +unautorisierten *Ausfuehrern*, aber nicht vor einer *gefaelschten Approval*. + +## Kennung + +``` +HUMAN_APPROVAL_AUTHENTICITY_GAP = OPEN +``` + +## Naechste Schritte (separate Freigabe erforderlich) + +- Eigene Security-Mission zur kryptographischen Bindung der Human-Approval + an Christian (z. B. Signatur, Nonce-Beweis, Out-of-Band-Verifikation). +- Kein Teil von AUTH.3A / AUTH.3B / AUTH.4.