Files
Orchestrator/shared/tools/monorepo-cli/tests/test_security.py
T
2026-06-30 20:37:40 +02:00

549 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit-Tests für ContextGuard und AuditLogger-Integration.
Testet die Zugriffskontrolle zwischen Arbeitskontexten:
- Zugriff innerhalb des eigenen Kontexts (erlaubt)
- Zugriff auf fremden Kontext (verweigert)
- Zugriff auf shared-Bereich (erlaubt gemäß Konfiguration)
- Audit-Log-Format und Vollständigkeit
- Edge Cases: shared-Kontext, Tool-Ausführung im shared-Kontext
Requirements: 2.1, 2.3, 2.5, 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import pytest
import yaml
from monorepo.audit import AuditLogger
from monorepo.models import SecurityEvent
from monorepo.security import ContextGuard
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def monorepo_root(tmp_path: Path) -> Path:
"""Erstellt eine Monorepo-Struktur mit access-config.yaml."""
root = tmp_path / "monorepo"
root.mkdir()
# Kontextordner erstellen
(root / "privat").mkdir()
(root / "dhive").mkdir()
(root / "bahn").mkdir()
(root / "shared" / "config").mkdir(parents=True)
(root / "shared" / "tools").mkdir(parents=True)
(root / "shared" / "powers").mkdir(parents=True)
(root / "shared" / "knowledge-store").mkdir(parents=True)
(root / "shared" / "mcp-servers").mkdir(parents=True)
# access-config.yaml erstellen
config = {
"contexts": {
"privat": {
"env_file": "privat/.env",
"allowed_shared": [
"shared/tools/",
"shared/powers/",
"shared/config/",
],
},
"dhive": {
"env_file": "dhive/.env",
"allowed_shared": [
"shared/tools/",
"shared/powers/",
"shared/config/",
],
},
"bahn": {
"env_file": "bahn/.env",
"allowed_shared": [
"shared/tools/",
"shared/powers/",
"shared/config/",
"shared/knowledge-store/",
],
},
"shared": {
"env_file": "shared/.env",
"allowed_shared": ["*"],
},
}
}
config_path = root / "shared" / "config" / "access-config.yaml"
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config, f)
return root
@pytest.fixture
def guard(monorepo_root: Path) -> ContextGuard:
"""Erstellt einen ContextGuard mit der Test-Konfiguration."""
return ContextGuard(root_path=monorepo_root)
@pytest.fixture
def env_files(monorepo_root: Path) -> Path:
"""Erstellt .env-Dateien für alle Kontexte."""
for ctx in ("privat", "dhive", "bahn", "shared"):
env_path = monorepo_root / ctx / ".env"
env_path.write_text(
f"# {ctx} secrets\n"
f"{ctx.upper()}_API_KEY=secret-{ctx}-123\n"
f"{ctx.upper()}_DB_URL=postgresql://localhost/{ctx}\n",
encoding="utf-8",
)
return monorepo_root
# ---------------------------------------------------------------------------
# Tests: Zugriff innerhalb des eigenen Kontexts (erlaubt)
# ---------------------------------------------------------------------------
class TestOwnContextAccess:
"""Teste Zugriff innerhalb des eigenen Kontexts (erlaubt).
Requirement 2.1, 2.3: Jeder Kontext darf auf eigene Dateien zugreifen.
"""
def test_privat_accesses_own_env(self, guard: ContextGuard, monorepo_root: Path) -> None:
"""privat darf auf privat/.env zugreifen."""
target = monorepo_root / "privat" / ".env"
assert guard.check_access("privat", target) is True
def test_dhive_accesses_own_env(self, guard: ContextGuard, monorepo_root: Path) -> None:
"""dhive darf auf dhive/.env zugreifen."""
target = monorepo_root / "dhive" / ".env"
assert guard.check_access("dhive", target) is True
def test_bahn_accesses_own_env(self, guard: ContextGuard, monorepo_root: Path) -> None:
"""bahn darf auf bahn/.env zugreifen."""
target = monorepo_root / "bahn" / ".env"
assert guard.check_access("bahn", target) is True
def test_privat_accesses_own_project_file(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf auf beliebige Dateien im eigenen Kontext zugreifen."""
target = monorepo_root / "privat" / "project-a" / "src" / "main.py"
assert guard.check_access("privat", target) is True
def test_dhive_accesses_own_nested_file(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""dhive darf auf tief verschachtelte Dateien im eigenen Kontext zugreifen."""
target = monorepo_root / "dhive" / "my-project" / "module" / "secret.key"
assert guard.check_access("dhive", target) is True
def test_shared_accesses_own_files(self, guard: ContextGuard, monorepo_root: Path) -> None:
"""shared-Kontext darf auf eigene Dateien zugreifen."""
target = monorepo_root / "shared" / ".env"
assert guard.check_access("shared", target) is True
# ---------------------------------------------------------------------------
# Tests: Zugriff auf fremden Kontext (verweigert)
# ---------------------------------------------------------------------------
class TestForeignContextAccessDenied:
"""Teste Zugriff auf fremden Kontext (verweigert).
Requirement 2.1, 2.3: Secrets eines Arbeitskontexts sind für andere
Kontexte nicht lesbar.
"""
def test_privat_cannot_access_dhive_env(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf NICHT auf dhive/.env zugreifen."""
target = monorepo_root / "dhive" / ".env"
assert guard.check_access("privat", target) is False
def test_privat_cannot_access_bahn_env(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf NICHT auf bahn/.env zugreifen."""
target = monorepo_root / "bahn" / ".env"
assert guard.check_access("privat", target) is False
def test_dhive_cannot_access_privat_env(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""dhive darf NICHT auf privat/.env zugreifen."""
target = monorepo_root / "privat" / ".env"
assert guard.check_access("dhive", target) is False
def test_dhive_cannot_access_bahn_secrets(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""dhive darf NICHT auf bahn/secret.key zugreifen."""
target = monorepo_root / "bahn" / "secret.key"
assert guard.check_access("dhive", target) is False
def test_bahn_cannot_access_privat_project(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""bahn darf NICHT auf Dateien im privat-Kontext zugreifen."""
target = monorepo_root / "privat" / "project-a" / "config.yaml"
assert guard.check_access("bahn", target) is False
def test_bahn_cannot_access_dhive_env(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""bahn darf NICHT auf dhive/.env zugreifen."""
target = monorepo_root / "dhive" / ".env"
assert guard.check_access("bahn", target) is False
# ---------------------------------------------------------------------------
# Tests: Zugriff auf shared-Bereich (erlaubt gemäß Konfiguration)
# ---------------------------------------------------------------------------
class TestSharedAreaAccess:
"""Teste Zugriff auf shared-Bereich (erlaubt gemäß Konfiguration).
Requirement 2.6, 2.7: Zugriff auf shared-Ressourcen nur auf explizit
freigegebene Pfade.
"""
def test_privat_accesses_shared_tools(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf auf shared/tools/ zugreifen."""
target = monorepo_root / "shared" / "tools" / "monorepo-cli" / "src" / "main.py"
assert guard.check_access("privat", target) is True
def test_privat_accesses_shared_powers(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf auf shared/powers/ zugreifen."""
target = monorepo_root / "shared" / "powers" / "db-platform" / "power.md"
assert guard.check_access("privat", target) is True
def test_privat_accesses_shared_config(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf auf shared/config/ zugreifen."""
target = monorepo_root / "shared" / "config" / "access-config.yaml"
assert guard.check_access("privat", target) is True
def test_privat_cannot_access_shared_knowledge_store(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf NICHT auf shared/knowledge-store/ zugreifen (nicht in allowed_shared)."""
target = monorepo_root / "shared" / "knowledge-store" / "index.yaml"
assert guard.check_access("privat", target) is False
def test_privat_cannot_access_shared_mcp_servers(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""privat darf NICHT auf shared/mcp-servers/ zugreifen (nicht in allowed_shared)."""
target = monorepo_root / "shared" / "mcp-servers" / "config.json"
assert guard.check_access("privat", target) is False
def test_bahn_accesses_shared_knowledge_store(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""bahn darf auf shared/knowledge-store/ zugreifen (in bahn's allowed_shared)."""
target = monorepo_root / "shared" / "knowledge-store" / "_index.yaml"
assert guard.check_access("bahn", target) is True
def test_dhive_cannot_access_shared_knowledge_store(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""dhive darf NICHT auf shared/knowledge-store/ zugreifen."""
target = monorepo_root / "shared" / "knowledge-store" / "data.md"
assert guard.check_access("dhive", target) is False
def test_dhive_accesses_shared_tools(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""dhive darf auf shared/tools/ zugreifen."""
target = monorepo_root / "shared" / "tools" / "script.sh"
assert guard.check_access("dhive", target) is True
# ---------------------------------------------------------------------------
# Tests: Shared-Kontext darf auf alles zugreifen (Wildcard)
# ---------------------------------------------------------------------------
class TestSharedContextWildcard:
"""Edge Case: shared-Kontext mit allowed_shared: ['*'] darf auf alles in shared zugreifen.
Requirement 2.7: Tools im shared-Bereich brauchen Zugriff auf shared-Ressourcen.
"""
def test_shared_accesses_shared_tools(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""shared darf auf shared/tools/ zugreifen."""
target = monorepo_root / "shared" / "tools" / "any-tool" / "main.py"
assert guard.check_access("shared", target) is True
def test_shared_accesses_shared_knowledge_store(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""shared darf auf shared/knowledge-store/ zugreifen."""
target = monorepo_root / "shared" / "knowledge-store" / "deep" / "doc.md"
assert guard.check_access("shared", target) is True
def test_shared_accesses_shared_mcp_servers(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""shared darf auf shared/mcp-servers/ zugreifen (Wildcard)."""
target = monorepo_root / "shared" / "mcp-servers" / "server.json"
assert guard.check_access("shared", target) is True
def test_shared_cannot_access_privat(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""shared darf NICHT auf privat/ zugreifen (Wildcard gilt nur für shared/)."""
target = monorepo_root / "privat" / ".env"
assert guard.check_access("shared", target) is False
def test_shared_cannot_access_dhive(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""shared darf NICHT auf dhive/ zugreifen."""
target = monorepo_root / "dhive" / "project" / "main.py"
assert guard.check_access("shared", target) is False
def test_shared_cannot_access_bahn(
self, guard: ContextGuard, monorepo_root: Path
) -> None:
"""shared darf NICHT auf bahn/ zugreifen."""
target = monorepo_root / "bahn" / ".env"
assert guard.check_access("shared", target) is False
# ---------------------------------------------------------------------------
# Tests: load_env Funktion
# ---------------------------------------------------------------------------
class TestLoadEnv:
"""Teste load_env-Methode: Lädt nur die eigene .env-Datei.
Requirement 2.2, 2.3: Jeder Kontext hat eine eigene .env-Datei.
"""
def test_load_privat_env(
self, guard: ContextGuard, env_files: Path, monorepo_root: Path
) -> None:
"""load_env lädt korrekt die privat/.env."""
env = guard.load_env("privat")
assert env["PRIVAT_API_KEY"] == "secret-privat-123"
assert env["PRIVAT_DB_URL"] == "postgresql://localhost/privat"
def test_load_dhive_env(
self, guard: ContextGuard, env_files: Path, monorepo_root: Path
) -> None:
"""load_env lädt korrekt die dhive/.env."""
env = guard.load_env("dhive")
assert env["DHIVE_API_KEY"] == "secret-dhive-123"
def test_load_unknown_context_raises(self, guard: ContextGuard) -> None:
"""load_env wirft ValueError bei unbekanntem Kontext."""
with pytest.raises(ValueError, match="Unbekannter Kontext"):
guard.load_env("unknown")
def test_load_env_missing_file_raises(self, guard: ContextGuard) -> None:
"""load_env wirft FileNotFoundError wenn .env-Datei nicht existiert."""
with pytest.raises(FileNotFoundError, match=".env-Datei"):
guard.load_env("privat")
# ---------------------------------------------------------------------------
# Tests: Audit-Log-Format und Vollständigkeit
# ---------------------------------------------------------------------------
class TestAuditLogFormatAndCompleteness:
"""Teste Audit-Log-Format und Vollständigkeit.
Requirement 2.5: Protokolleintrag muss enthalten:
- Zeitstempel
- anfragender Kontext
- Zielkontext
- angefragte Ressource
"""
def test_audit_log_contains_all_required_fields(self, tmp_path: Path) -> None:
"""Ein Audit-Eintrag enthält alle Pflichtfelder: Zeitstempel, Kontexte, Ressource."""
log_path = tmp_path / "audit.log"
logger = AuditLogger(log_path=log_path)
event = SecurityEvent(
timestamp=datetime(2025, 7, 1, 9, 15, 30),
requesting_context="dhive",
target_context="privat",
resource="privat/.env",
action="read",
outcome="denied",
)
logger.log_violation(event)
content = log_path.read_text(encoding="utf-8")
# Zeitstempel vorhanden (ISO-Format)
assert "2025-07-01T09:15:30" in content
# Anfragender Kontext vorhanden
assert "dhive" in content
# Zielkontext vorhanden
assert "privat" in content
# Ressource vorhanden
assert "privat/.env" in content
# Aktion vorhanden
assert "read" in content
# Outcome vorhanden
assert "DENIED" in content
def test_audit_log_format_structure(self, tmp_path: Path) -> None:
"""Audit-Log-Einträge folgen dem definierten Format-Schema."""
log_path = tmp_path / "audit.log"
logger = AuditLogger(log_path=log_path)
event = SecurityEvent(
timestamp=datetime(2025, 3, 20, 12, 0, 0),
requesting_context="bahn",
target_context="dhive",
resource="dhive/.env",
action="write",
outcome="denied",
)
logger.log_violation(event)
line = log_path.read_text(encoding="utf-8").strip()
# Format: [ISO-TIMESTAMP] OUTCOME | requesting -> target | action on resource
assert line == "[2025-03-20T12:00:00] DENIED | bahn -> dhive | write on dhive/.env"
def test_audit_log_allowed_event(self, tmp_path: Path) -> None:
"""Auch erlaubte Zugriffe können protokolliert werden."""
log_path = tmp_path / "audit.log"
logger = AuditLogger(log_path=log_path)
event = SecurityEvent(
timestamp=datetime(2025, 5, 10, 8, 30, 0),
requesting_context="privat",
target_context="shared",
resource="shared/tools/script.sh",
action="execute",
outcome="allowed",
)
logger.log_violation(event)
content = log_path.read_text(encoding="utf-8")
assert "ALLOWED" in content
assert "privat -> shared" in content
assert "execute on shared/tools/script.sh" in content
def test_audit_log_completeness_multiple_events(self, tmp_path: Path) -> None:
"""Mehrere Events werden vollständig und geordnet protokolliert."""
log_path = tmp_path / "audit.log"
logger = AuditLogger(log_path=log_path)
events = [
SecurityEvent(
timestamp=datetime(2025, 1, 1, i, 0, 0),
requesting_context=f"ctx{i}",
target_context="privat",
resource=f"privat/file{i}.txt",
action="read",
outcome="denied",
)
for i in range(3)
]
for event in events:
logger.log_violation(event)
lines = log_path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 3
# Jede Zeile enthält die korrekten Daten
for i, line in enumerate(lines):
assert f"ctx{i}" in line
assert f"file{i}.txt" in line
assert "DENIED" in line
# ---------------------------------------------------------------------------
# Tests: ContextGuard Initialisierung und Konfiguration
# ---------------------------------------------------------------------------
class TestContextGuardInit:
"""Teste ContextGuard-Initialisierung und Fehlerbehandlung.
Requirement 2.6: Zentrale Konfigurationsdatei.
"""
def test_init_with_valid_config(self, monorepo_root: Path) -> None:
"""ContextGuard wird mit gültiger Konfiguration korrekt initialisiert."""
guard = ContextGuard(root_path=monorepo_root)
assert guard.root_path == monorepo_root.resolve()
def test_init_missing_config_raises(self, tmp_path: Path) -> None:
"""ContextGuard wirft FileNotFoundError bei fehlender Konfiguration."""
with pytest.raises(FileNotFoundError, match="Access-Konfiguration nicht gefunden"):
ContextGuard(root_path=tmp_path)
def test_init_invalid_yaml_raises(self, tmp_path: Path) -> None:
"""ContextGuard wirft ValueError bei ungültigem YAML-Format."""
config_dir = tmp_path / "shared" / "config"
config_dir.mkdir(parents=True)
config_path = config_dir / "access-config.yaml"
config_path.write_text("invalid: data\n", encoding="utf-8")
with pytest.raises(ValueError, match="Ungültiges access-config.yaml-Format"):
ContextGuard(root_path=tmp_path)
def test_get_context_config(self, guard: ContextGuard) -> None:
"""get_context_config gibt die korrekte Konfiguration zurück."""
config = guard.get_context_config("bahn")
assert config["env_file"] == "bahn/.env"
assert "shared/knowledge-store/" in config["allowed_shared"]
def test_get_context_config_unknown_raises(self, guard: ContextGuard) -> None:
"""get_context_config wirft ValueError bei unbekanntem Kontext."""
with pytest.raises(ValueError, match="Unbekannter Kontext"):
guard.get_context_config("nonexistent")
# ---------------------------------------------------------------------------
# Tests: Edge Cases relative Pfade
# ---------------------------------------------------------------------------
class TestRelativePaths:
"""Teste check_access mit relativen Pfaden."""
def test_relative_path_own_context(self, guard: ContextGuard) -> None:
"""Relativer Pfad innerhalb eigenen Kontexts wird erkannt."""
target = Path("privat/.env")
assert guard.check_access("privat", target) is True
def test_relative_path_foreign_context(self, guard: ContextGuard) -> None:
"""Relativer Pfad zu fremdem Kontext wird verweigert."""
target = Path("dhive/.env")
assert guard.check_access("privat", target) is False
def test_relative_path_shared(self, guard: ContextGuard) -> None:
"""Relativer Pfad zu erlaubtem shared-Bereich wird akzeptiert."""
target = Path("shared/tools/monorepo-cli/main.py")
assert guard.check_access("privat", target) is True