- 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.
444 lines
18 KiB
Python
444 lines
18 KiB
Python
#!/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()
|