Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user