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

362 lines
13 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.
"""Sicherheits-Guard für die Zugriffskontrolle zwischen Arbeitskontexten.
Implementiert den ContextGuard, der Sicherheitsgrenzen zwischen den
Arbeitskontexten (privat, dhive, bahn, shared) erzwingt. Die Zugriffskontrolle
basiert auf der zentralen access-config.yaml.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
from monorepo.models import Context
logger = logging.getLogger(__name__)
class ContextGuard:
"""Erzwingt Sicherheitsgrenzen zwischen Arbeitskontexten.
Prüft ob ein anfragender Kontext auf einen gegebenen Pfad zugreifen darf,
basierend auf den Regeln in access-config.yaml:
- Ein Kontext darf immer auf eigene Dateien zugreifen.
- Ein Kontext darf auf shared-Pfade zugreifen, die in allowed_shared gelistet sind.
- Der shared-Kontext mit ["*"] darf auf alles in shared zugreifen.
- Alle anderen kontextübergreifenden Zugriffe werden verweigert.
"""
def __init__(
self,
root_path: Path,
access_config_path: Path | None = None,
encryption_manager: Any | None = None,
) -> None:
"""Initialisiert den ContextGuard.
Args:
root_path: Wurzelverzeichnis des Monorepos.
access_config_path: Pfad zur access-config.yaml.
Falls None, wird `root_path / shared/config/access-config.yaml` verwendet.
encryption_manager: Optionaler SecretEncryptionManager für die
Entschlüsselung von .env-Dateien. Falls None, werden .env-Dateien
direkt gelesen (unverschlüsselt oder bereits durch git-crypt entschlüsselt).
"""
self.root_path = root_path.resolve()
if access_config_path is None:
access_config_path = self.root_path / "shared" / "config" / "access-config.yaml"
self.access_config_path = access_config_path.resolve()
self.encryption_manager = encryption_manager
self._config: dict[str, Any] = self._load_access_config()
def _load_access_config(self) -> dict[str, Any]:
"""Lädt die access-config.yaml.
Returns:
Dict mit der Zugriffskonfiguration pro Kontext.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
ValueError: Wenn das YAML-Format ungültig ist.
"""
if not self.access_config_path.exists():
raise FileNotFoundError(
f"Access-Konfiguration nicht gefunden: {self.access_config_path}"
)
with open(self.access_config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "contexts" not in data:
raise ValueError(
f"Ungültiges access-config.yaml-Format: "
f"Erwartet dict mit 'contexts'-Schlüssel in {self.access_config_path}"
)
return data["contexts"]
def check_access(self, requesting_context: str, target_path: Path) -> bool:
"""Prüft ob der Zugriff auf target_path vom requesting_context erlaubt ist.
Regeln:
1. Ein Kontext darf immer auf eigene Dateien zugreifen (Pfad unter eigenem Kontextordner).
2. Ein Kontext darf auf shared-Pfade zugreifen, wenn diese in allowed_shared gelistet sind.
3. Der shared-Kontext mit allowed_shared: ["*"] darf auf alles in shared zugreifen.
4. Alle anderen kontextübergreifenden Zugriffe werden verweigert.
Args:
requesting_context: Name des anfragenden Kontexts (z.B. "privat", "dhive", "bahn", "shared").
target_path: Pfad auf den zugegriffen werden soll (absolut oder relativ zum root_path).
Returns:
True wenn der Zugriff erlaubt ist, False wenn verweigert.
"""
# Pfad relativ zum Root normalisieren
rel_path = self._resolve_relative_path(target_path)
rel_path_str = rel_path.as_posix()
# Kontext des Zielpfads ermitteln
target_context = self._get_path_context(rel_path_str)
# Regel 1: Zugriff auf eigenen Kontext immer erlaubt
if target_context == requesting_context:
return True
# Regel 2+3: Zugriff auf shared-Bereich prüfen
if target_context == Context.SHARED.value:
return self._check_shared_access(requesting_context, rel_path_str)
# Regel 4: Kontextübergreifender Zugriff verweigert
return False
def load_env(self, context: str) -> dict[str, str]:
"""Lädt die .env-Datei des gegebenen Kontexts (mit optionaler Entschlüsselung).
Wenn ein SecretEncryptionManager konfiguriert ist und der Maschinenkontext
für den angefragten Kontext autorisiert ist, wird die .env-Datei via
decrypt_file entschlüsselt. Andernfalls wird die Datei direkt gelesen
(unverschlüsselt oder bereits durch git-crypt entschlüsselt im Working Tree).
Parst eine Standard-.env-Datei (KEY=VALUE-Format) und gibt die
Umgebungsvariablen als Dict zurück. Kommentare (#) und Leerzeilen
werden übersprungen.
Args:
context: Name des Kontexts, dessen .env geladen werden soll.
Returns:
Dict mit den Umgebungsvariablen (Schlüssel → Wert).
Raises:
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
FileNotFoundError: Wenn die .env-Datei nicht existiert.
PermissionError: Wenn die Entschlüsselung fehlschlägt
(Maschinenkontext nicht autorisiert).
"""
if context not in self._config:
raise ValueError(
f"Unbekannter Kontext '{context}'. "
f"Gültige Kontexte: {list(self._config.keys())}"
)
env_file_rel = self._config[context].get("env_file")
if not env_file_rel:
raise ValueError(
f"Kein env_file für Kontext '{context}' in der Konfiguration definiert."
)
env_path = self.root_path / env_file_rel
if not env_path.exists():
raise FileNotFoundError(
f".env-Datei für Kontext '{context}' nicht gefunden: {env_path}"
)
# Integration mit SecretEncryptionManager für Entschlüsselung (Req 2.2, 9.3).
if self.encryption_manager is not None:
return self._load_env_with_decryption(env_path, context)
# Fallback: Datei direkt lesen (unverschlüsselt oder bereits entschlüsselt)
return self._parse_env_file(env_path)
def _load_env_with_decryption(
self, env_path: Path, context: str
) -> dict[str, str]:
"""Lädt und entschlüsselt eine .env-Datei via SecretEncryptionManager.
Prüft zuerst die Autorisierung des Maschinenkontexts. Wenn autorisiert,
wird die Datei entschlüsselt und geparst. Andernfalls wird ein
PermissionError ausgelöst.
Args:
env_path: Absoluter Pfad zur .env-Datei.
context: Arbeitskontext der .env-Datei.
Returns:
Dict mit den entschlüsselten Umgebungsvariablen.
Raises:
PermissionError: Wenn der Maschinenkontext nicht autorisiert ist.
"""
encryption_mgr = self.encryption_manager
# Autorisierungsprüfung
if not encryption_mgr.is_authorized(context):
raise PermissionError(
f"Maschinenkontext '{encryption_mgr.machine_context.name}' ist nicht "
f"für Kontext '{context}' autorisiert. Entschlüsselung verweigert."
)
# Entschlüsselung versuchen
result = encryption_mgr.decrypt_file(env_path)
if result.success and result.content is not None:
# Entschlüsselter Inhalt als Text parsen
content_text = result.content.decode("utf-8", errors="replace")
return self._parse_env_content(content_text)
# Entschlüsselung fehlgeschlagen Fallback auf direkte Leseoperation.
# Dies tritt auf wenn die Datei bereits im Klartext vorliegt (z.B.
# nach git-crypt unlock) oder wenn git-crypt nicht verfügbar ist.
logger.debug(
"Entschlüsselung für '%s' nicht erfolgreich (%s). "
"Fallback auf direktes Lesen.",
env_path,
result.error or "unbekannter Fehler",
)
return self._parse_env_file(env_path)
def get_context_config(self, context: str) -> dict[str, Any]:
"""Gibt die Konfiguration für einen bestimmten Kontext zurück.
Args:
context: Name des Kontexts.
Returns:
Dict mit env_file und allowed_shared für den Kontext.
Raises:
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
"""
if context not in self._config:
raise ValueError(
f"Unbekannter Kontext '{context}'. "
f"Gültige Kontexte: {list(self._config.keys())}"
)
return self._config[context]
def _resolve_relative_path(self, target_path: Path) -> Path:
"""Löst einen Pfad relativ zum Monorepo-Root auf.
Args:
target_path: Absoluter oder relativer Pfad.
Returns:
Pfad relativ zum Root (immer mit Forward-Slashes via as_posix).
"""
resolved = Path(target_path).resolve() if target_path.is_absolute() else target_path
try:
return resolved.relative_to(self.root_path)
except ValueError:
# Pfad ist bereits relativ oder liegt außerhalb des Roots
return target_path
def _get_path_context(self, rel_path_str: str) -> str:
"""Ermittelt den Kontext eines relativen Pfads.
Der Kontext wird anhand des obersten Verzeichnisses bestimmt.
Gültige Kontexte: privat, dhive, bahn, shared.
Args:
rel_path_str: Relativer Pfad als String (mit Forward-Slashes).
Returns:
Name des Kontexts oder leerer String wenn nicht zuordenbar.
"""
parts = rel_path_str.split("/")
if not parts:
return ""
first_part = parts[0]
valid_contexts = {ctx.value for ctx in Context}
if first_part in valid_contexts:
return first_part
return ""
def _check_shared_access(self, requesting_context: str, rel_path_str: str) -> bool:
"""Prüft ob ein Kontext auf einen shared-Pfad zugreifen darf.
Args:
requesting_context: Name des anfragenden Kontexts.
rel_path_str: Relativer Pfad (beginnt mit "shared/").
Returns:
True wenn der Zugriff erlaubt ist.
"""
if requesting_context not in self._config:
return False
allowed_shared = self._config[requesting_context].get("allowed_shared", [])
# Wildcard: Zugriff auf alles in shared erlaubt
if allowed_shared == ["*"]:
return True
# Prüfe ob der Pfad unter einem der erlaubten shared-Pfade liegt
for allowed_path in allowed_shared:
# Normalisiere: Stelle sicher dass der Pfad mit / endet für Prefix-Matching
allowed_normalized = allowed_path.rstrip("/") + "/"
# Pfad liegt unter dem erlaubten Verzeichnis oder ist es selbst
if rel_path_str.startswith(allowed_normalized) or rel_path_str.rstrip("/") + "/" == allowed_normalized:
return True
return False
@staticmethod
def _parse_env_file(env_path: Path) -> dict[str, str]:
"""Parst eine .env-Datei im Standard-Format.
Format:
- KEY=VALUE (ein Paar pro Zeile)
- Zeilen die mit # beginnen sind Kommentare
- Leerzeilen werden übersprungen
- Führende/nachfolgende Leerzeichen werden entfernt
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
Args:
env_path: Pfad zur .env-Datei.
Returns:
Dict mit geparsten Umgebungsvariablen.
"""
with open(env_path, encoding="utf-8") as f:
content = f.read()
return ContextGuard._parse_env_content(content)
@staticmethod
def _parse_env_content(content: str) -> dict[str, str]:
"""Parst .env-Inhalt im Standard-Format aus einem String.
Format:
- KEY=VALUE (ein Paar pro Zeile)
- Zeilen die mit # beginnen sind Kommentare
- Leerzeilen werden übersprungen
- Führende/nachfolgende Leerzeichen werden entfernt
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
Args:
content: Textinhalt der .env-Datei.
Returns:
Dict mit geparsten Umgebungsvariablen.
"""
env_vars: dict[str, str] = {}
for line in content.splitlines():
line = line.strip()
# Leerzeilen und Kommentare überspringen
if not line or line.startswith("#"):
continue
# KEY=VALUE aufteilen (nur beim ersten = splitten)
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Anführungszeichen entfernen
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
if key:
env_vars[key] = value
return env_vars