diff --git a/docs/FILEPERMISSION_EXCEPTION_IMPROVEMENT_PLAN.md b/docs/FILEPERMISSION_EXCEPTION_IMPROVEMENT_PLAN.md new file mode 100644 index 00000000..89f3e02d --- /dev/null +++ b/docs/FILEPERMISSION_EXCEPTION_IMPROVEMENT_PLAN.md @@ -0,0 +1,226 @@ +# FilePermissionException Verbesserungs-Plan + +## Ziel + +Verbesserung der `FilePermissionException` um detaillierte Permission- und User-Informationen f?r besseres Debugging. + +## Problem-Analyse + +### Aktueller Zustand + +Die `FilePermissionException` gibt momentan nur minimale Informationen: + +```php +FilePermissionException::delete($path, 'Directory is not writable: ' . $dir); +// Output: "Permission denied for delete on file: /path/to/file (Directory is not writable: /path/to/dir)" +``` + +**Fehlende Informationen:** +- Aktuelle Permissions der Datei/Verzeichnisses +- Owner/Group der Datei +- Aktueller Process-User +- Erwartete Permissions +- Octal-Darstellung der Permissions + +### Verf?gbare Infrastruktur + +? **PermissionChecker** hat bereits `getDiagnosticInfo()`: +- Liefert owner, group, permissions, parent_dir info +- Nutzt `posix_getpwuid()` und `posix_getgrgid()` + +? **ExceptionContext** hat `withData()`, `withDebug()`, `withMetadata()` + +? **posix_geteuid()** verf?gbar f?r Process-User + +## L?sungsplan + +### L?sung 1: Erweitere FilePermissionException (HOHE PRIORIT?T) + +**Neue statische Factory-Methoden mit Permission-Details:** + +```php +public static function delete( + string $path, + ?string $reason = null, + ?PermissionChecker $permissionChecker = null +): self { + $diagnosticInfo = $permissionChecker?->getDiagnosticInfo($path) ?? []; + $currentUser = self::getCurrentUserInfo(); + + return new self( + path: $path, + operation: 'delete', + reason: $reason, + diagnosticInfo: $diagnosticInfo, + currentUser: $currentUser + ); +} +``` + +**Neue Properties:** +- `?array $diagnosticInfo` - Permission-Details vom PermissionChecker +- `?array $currentUser` - Aktueller Process-User Info + +**Verbesserte Message-Formatierung:** +```php +$message = "Permission denied for {$operation} on file: {$path}"; +if ($reason) { + $message .= " ({$reason})"; +} + +// Add detailed info if available +if ($this->diagnosticInfo) { + $message .= sprintf( + "\n File owner: %s, group: %s, permissions: %s", + $this->diagnosticInfo['owner'] ?? 'unknown', + $this->diagnosticInfo['group'] ?? 'unknown', + $this->diagnosticInfo['permissions'] ?? 'unknown' + ); + + if (isset($this->diagnosticInfo['parent_dir'])) { + $parentInfo = $permissionChecker?->getDiagnosticInfo($this->diagnosticInfo['parent_dir']) ?? []; + $message .= sprintf( + "\n Parent directory: %s (owner: %s, permissions: %s)", + $this->diagnosticInfo['parent_dir'], + $parentInfo['owner'] ?? 'unknown', + $parentInfo['permissions'] ?? 'unknown' + ); + } +} + +if ($this->currentUser) { + $message .= sprintf( + "\n Current process user: %s (uid: %d, gid: %d)", + $this->currentUser['name'], + $this->currentUser['uid'], + $this->currentUser['gid'] + ); +} +``` + +### L?sung 2: Helper-Methode f?r Process-User Info + +**Neue private Methode in FilePermissionException:** + +```php +private static function getCurrentUserInfo(): ?array +{ + if (!function_exists('posix_geteuid')) { + return null; + } + + $uid = posix_geteuid(); + $gid = posix_getegid(); + $userInfo = posix_getpwuid($uid); + $groupInfo = posix_getgrgid($gid); + + return [ + 'uid' => $uid, + 'gid' => $gid, + 'name' => $userInfo['name'] ?? 'unknown', + 'group' => $groupInfo['name'] ?? 'unknown', + ]; +} +``` + +### L?sung 3: Integration in FileStorage-Methoden + +**Anpassung aller FilePermissionException Aufrufe:** + +**Vorher:** +```php +if (! is_writable($dir)) { + throw FilePermissionException::delete($path, 'Directory is not writable: ' . $dir); +} +``` + +**Nachher:** +```php +if (! is_writable($dir)) { + throw FilePermissionException::delete( + path: $path, + reason: 'Directory is not writable: ' . $dir, + permissionChecker: $this->permissions + ); +} +``` + +**Betroffene Stellen:** +1. `FileStorage::get()` - read exception +2. `FileStorage::put()` - write exception, createDirectory exception +3. `FileStorage::delete()` - delete exception +4. `FileStorage::createDirectory()` - createDirectory exception +5. Alle anderen Stellen, die FilePermissionException werfen + +### L?sung 4: ExceptionContext erweitern + +**Erweiterte Context-Daten in Exception:** + +```php +$context = ExceptionContext::forOperation('file.permission', 'filesystem') + ->withData([ + 'path' => $path, + 'operation' => $operation, + 'reason' => $reason, + ]) + ->withDebug([ + 'file_permissions' => $diagnosticInfo['permissions'] ?? null, + 'file_owner' => $diagnosticInfo['owner'] ?? null, + 'file_group' => $diagnosticInfo['group'] ?? null, + 'parent_dir' => $diagnosticInfo['parent_dir'] ?? null, + 'parent_writable' => $diagnosticInfo['parent_writable'] ?? null, + ]) + ->withMetadata([ + 'current_user' => $currentUser, + 'file_exists' => $diagnosticInfo['exists'] ?? false, + 'is_file' => $diagnosticInfo['is_file'] ?? false, + 'is_dir' => $diagnosticInfo['is_dir'] ?? false, + ]); +``` + +## Implementierungsreihenfolge + +1. ? **L?sung 2** - Helper-Methode f?r Process-User (einfach, keine Dependencies) +2. ? **L?sung 1** - Erweitere FilePermissionException Constructor und Factory-Methoden +3. ? **L?sung 3** - Integration in FileStorage-Methoden +4. ? **L?sung 4** - ExceptionContext erweitern + +## Beispiel-Output + +### Vorher: +``` +Permission denied for delete on file: /var/www/html/storage/cache/file.cache.php (Directory is not writable: /var/www/html/storage/cache) +``` + +### Nachher: +``` +Permission denied for delete on file: /var/www/html/storage/cache/file.cache.php (Directory is not writable: /var/www/html/storage/cache) + File owner: www-data, group: www-data, permissions: drwxrwxr-x + Parent directory: /var/www/html/storage/cache (owner: root, permissions: drwxr-xr-x) + Current process user: www-data (uid: 33, gid: 33) +``` + +## Vorteile + +1. **Besseres Debugging**: Sofort sichtbar, welche Permissions falsch sind +2. **User-Info**: Zeigt Owner-Mismatch sofort +3. **Context**: Vollst?ndige Permission-Info in Exception-Context f?r Logging +4. **R?ckw?rtskompatibel**: Alte Aufrufe funktionieren weiterhin (optional Parameter) + +## Performance-?berlegungen + +- `getDiagnosticInfo()` ist relativ teuer (mehrere `stat()` Calls) +- **L?sung**: Nur bei Exception-Throw aufrufen (nicht bei jedem Check) +- Optional: K?nnte in `withDebug()` ausgelagert werden, wenn Performance kritisch ist + +## Testing + +1. **Unit-Tests**: FilePermissionException mit Permission-Details +2. **Integration-Tests**: FileStorage-Methoden mit verschiedenen Permission-Szenarien +3. **E2E-Tests**: Cache-Operationen mit Permission-Problemen + +## Dokumentation + +Nach Implementierung aktualisieren: +- `docs/claude/error-handling.md` - FilePermissionException mit Details +- `docs/deployment/cache-configuration.md` - Exception-Beispiele diff --git a/docs/REDIS_ACL_EXPLANATION.md b/docs/REDIS_ACL_EXPLANATION.md new file mode 100644 index 00000000..cbb8d3c5 --- /dev/null +++ b/docs/REDIS_ACL_EXPLANATION.md @@ -0,0 +1,388 @@ +# Redis ACL (Access Control List) - Erkl?rung + +## Was ist Redis ACL? + +**Redis ACL (Access Control List)** ist ein Authentifizierungs- und Autorisierungssystem, das in Redis 6.0 eingef?hrt wurde. Es erm?glicht feingranulare Kontrolle ?ber Benutzerrechte und Zugriff auf Redis-Befehle und -Daten. + +### Unterschied: Legacy vs. ACL + +#### Legacy Authentication (vor Redis 6.0) + +**Wie es funktioniert:** +- Ein einzelnes Passwort f?r die gesamte Redis-Instanz +- Konfiguration via `requirepass` in `redis.conf` oder `--requirepass` Parameter +- Alle verbundenen Clients haben **vollst?ndigen Zugriff** auf alle Daten und Befehle +- Keine Unterscheidung zwischen Benutzern + +**Beispiel:** +```bash +# redis.conf +requirepass "mein-passwort-123" + +# Oder via Command Line +redis-server --requirepass "mein-passwort-123" +``` + +**PHP Code:** +```php +$redis = new Redis(); +$redis->connect('localhost', 6379); +$redis->auth('mein-passwort-123'); // Einfache Passwort-Auth +// Jetzt hat der Client VOLLST?NDIGEN Zugriff +``` + +**Probleme:** +- ? Keine Benutzer-Trennung +- ? Keine feingranulare Kontrolle +- ? Alle Clients haben Admin-Rechte +- ? Keine M?glichkeit, verschiedene Berechtigungen zu vergeben + +#### Redis ACL (ab Redis 6.0) + +**Wie es funktioniert:** +- Mehrere Benutzer mit unterschiedlichen Berechtigungen +- Jeder Benutzer hat Username + Passwort +- Feingranulare Kontrolle ?ber: + - Welche Befehle erlaubt sind + - Auf welche Keys/Datenbanken zugegriffen werden kann + - Lese-/Schreib-Rechte + - Spezifische Key-Patterns + +**Beispiel:** +```bash +# Redis CLI +ACL SETUSER cache-user on >cache-password123 ~cache:* +get +set +del +exists +ACL SETUSER queue-user on >queue-password456 ~queue:* +lpush +rpop +llen +ACL SETUSER admin on >admin-password789 ~* &* +@all +``` + +**PHP Code:** +```php +// F?r Cache-Benutzer +$redis = new Redis(); +$redis->connect('localhost', 6379); +$redis->auth('cache-user', 'cache-password123'); // Username + Password + +// F?r Admin +$redis->auth('admin', 'admin-password789'); // Username + Password +``` + +## ACL Konzepte im Detail + +### 1. Benutzer (Users) + +**Standard-Benutzer:** +- `default`: Voreingestellter Benutzer (ohne Passwort = kein Auth erforderlich) +- Benutzer k?nnen aktiv (`on`) oder deaktiviert (`off`) sein + +**Beispiel:** +```redis +ACL SETUSER myuser on >mypassword +``` + +### 2. Passw?rter + +**Passwort-Formate:** +- Plaintext: `>password123` +- Hash: `#5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8` + +**Mehrere Passw?rter:** +```redis +ACL SETUSER myuser on >password1 >password2 >password3 +``` + +### 3. Key-Patterns (Zugriff auf Keys) + +**Pattern-Syntax:** +- `~*`: Zugriff auf alle Keys +- `~cache:*`: Zugriff nur auf Keys die mit `cache:` beginnen +- `~user:*`: Zugriff nur auf Keys die mit `user:` beginnen +- `~{user,cache}:*`: Zugriff auf mehrere Patterns + +**Beispiel:** +```redis +ACL SETUSER cache-user on >pass ~cache:* ~session:* +``` + +### 4. Befehle (Commands) + +**Berechtigungen:** +- `+command`: Befehl erlauben (z.B. `+get`, `+set`) +- `-command`: Befehl verbieten (z.B. `-flushdb`) +- `+@category`: Alle Befehle einer Kategorie erlauben +- `-@category`: Alle Befehle einer Kategorie verbieten +- `+@all`: Alle Befehle erlauben (Admin) +- `+@read`: Nur Lese-Befehle +- `+@write`: Nur Schreib-Befehle +- `+@keyspace`: Keyspace-Befehle +- `+@string`: String-Befehle +- `+@list`: List-Befehle +- `+@set`: Set-Befehle +- `+@sortedset`: Sorted Set-Befehle +- `+@hash`: Hash-Befehle +- `+@stream`: Stream-Befehle +- `+@pubsub`: Pub/Sub-Befehle +- `+@transaction`: Transaction-Befehle +- `+@connection`: Connection-Befehle +- `+@scripting`: Scripting-Befehle +- `+@admin`: Admin-Befehle +- `+@dangerous`: Gef?hrliche Befehle (flush, debug, etc.) + +**Beispiel:** +```redis +# Cache-User: Nur GET, SET, DEL, EXISTS +ACL SETUSER cache-user on >pass ~cache:* +get +set +del +exists + +# Queue-User: Nur List-Operationen +ACL SETUSER queue-user on >pass ~queue:* +lpush +rpop +llen +lrange + +# Admin: Alle Befehle +ACL SETUSER admin on >pass ~* &* +@all +``` + +### 5. Datenbanken (Databases) + +**Standard:** +- Alle Datenbanken sind erlaubt (`&*`) +- Spezifische Datenbanken: `&0`, `&1`, `&2`, etc. + +**Beispiel:** +```redis +# Nur Datenbank 1 erlauben +ACL SETUSER myuser on >pass ~* &1 +@all +``` + +## Praktische Beispiele + +### Beispiel 1: Cache-User (nur Lese/Schreib auf Cache-Keys) + +```redis +ACL SETUSER cache-user on >cache-secret-password ~cache:* +get +set +del +exists +expire +ttl +``` + +**Was darf dieser User?** +- ? `GET cache:user:123` +- ? `SET cache:user:123 "value"` +- ? `DEL cache:user:123` +- ? `EXISTS cache:user:123` +- ? `GET user:123` (nicht `cache:` Prefix) +- ? `FLUSHDB` (nicht erlaubt) +- ? `KEYS *` (nicht erlaubt) + +### Beispiel 2: Queue-User (nur Queue-Operationen) + +```redis +ACL SETUSER queue-user on >queue-secret-password ~queue:* +lpush +rpop +llen +lrange +lrem +@list +``` + +**Was darf dieser User?** +- ? `LPUSH queue:emails "task"` +- ? `RPOP queue:emails` +- ? `LLEN queue:emails` +- ? `GET queue:emails` (String-Befehle nicht erlaubt) +- ? `FLUSHDB` (nicht erlaubt) + +### Beispiel 3: Admin-User (vollst?ndiger Zugriff) + +```redis +ACL SETUSER admin on >admin-secret-password ~* &* +@all +``` + +**Was darf dieser User?** +- ? Alle Befehle +- ? Alle Keys +- ? Alle Datenbanken + +### Beispiel 4: Read-Only User (nur Lese-Zugriff) + +```redis +ACL SETUSER readonly-user on >readonly-password ~* +@read +``` + +**Was darf dieser User?** +- ? `GET`, `HGET`, `SMEMBERS`, etc. (Lese-Befehle) +- ? `SET`, `DEL`, `FLUSHDB` (Schreib-Befehle) + +## ACL vs. Legacy in unserem Framework + +### Aktuelle Implementierung (Legacy) + +**Konfiguration:** +```yaml +# docker-compose.staging.yml +redis-server --requirepass "password123" +``` + +**PHP Code:** +```php +// RedisConfig.php +password: $env->get(EnvKey::REDIS_PASSWORD, null) + +// RedisConnection.php +$redis->auth($this->config->password); // Einfache Passwort-Auth +``` + +**Problem:** +- Alle Verbindungen (cache, queue, session) verwenden dasselbe Passwort +- Alle haben vollst?ndigen Zugriff auf alle Daten +- Keine Isolation zwischen verschiedenen Use Cases + +### Potenzielle ACL-Implementierung + +**Konfiguration:** +```yaml +# docker-compose.staging.yml +# Redis startet mit ACL +# ACL wird via init script oder config gesetzt: +ACL SETUSER cache-user on >cache-password ~cache:* +@read +@write -@dangerous +ACL SETUSER queue-user on >queue-password ~queue:* +@list +@read -@dangerous +ACL SETUSER session-user on >session-password ~session:* +@read +@write -@dangerous +ACL SETUSER admin on >admin-password ~* &* +@all +``` + +**Environment Variables:** +```env +# F?r Cache-Connection +REDIS_USERNAME=cache-user +REDIS_PASSWORD=cache-password + +# F?r Queue-Connection +REDIS_USERNAME=queue-user +REDIS_PASSWORD=queue-password + +# F?r Session-Connection +REDIS_USERNAME=session-user +REDIS_PASSWORD=session-password +``` + +**PHP Code:** +```php +// RedisConfig.php +public ?string $username = null; +public ?string $password = null; + +// RedisConnection.php +if ($this->config->password) { + if ($this->config->username) { + // ACL: auth(username, password) + $authResult = $this->client->auth( + $this->config->username, + $this->config->password + ); + } else { + // Legacy: auth(password) + $authResult = $this->client->auth($this->config->password); + } +} +``` + +**Vorteile:** +- ? Isolation: Cache-User kann nicht auf Queue-Keys zugreifen +- ? Sicherheit: Minimale Berechtigungen (Principle of Least Privilege) +- ? Audit: Verschiedene User f?r verschiedene Operationen +- ? Schutz: Kein User kann `FLUSHDB` ausf?hren (au?er Admin) + +## Wann ACL verwenden? + +### ACL ist sinnvoll wenn: + +1. **Multi-Tenant Umgebungen** + - Verschiedene Anwendungen teilen sich Redis + - Jede Anwendung braucht isolierten Zugriff + +2. **Sicherheits-Anforderungen** + - Compliance-Anforderungen (z.B. PCI-DSS) + - Feingranulare Zugriffskontrolle erforderlich + +3. **Team-Isolation** + - Verschiedene Teams haben verschiedene Bereiche + - Cache-Team, Queue-Team, Session-Team + +4. **Production vs. Staging** + - Staging: Weniger restriktiv + - Production: Strenge ACL-Regeln + +### Legacy Auth ist ausreichend wenn: + +1. **Single-Application** + - Nur eine Anwendung nutzt Redis + - Keine Multi-Tenant-Anforderungen + +2. **Einfache Setups** + - Entwicklungs-Umgebungen + - Kleine Projekte ohne hohe Sicherheitsanforderungen + +3. **Externe Isolation** + - Redis l?uft in isoliertem Netzwerk + - Zugriff wird auf Netzwerk-Ebene kontrolliert + +## Migration von Legacy zu ACL + +### Schritt 1: Redis mit ACL konfigurieren + +```bash +# Redis startet mit Legacy Auth +# Dann ACL konfigurieren: + +# Legacy User erstellen (f?r Backward Compatibility) +ACL SETUSER default on >legacy-password ~* &* +@all + +# Spezialisierte User erstellen +ACL SETUSER cache-user on >cache-password ~cache:* +@read +@write +ACL SETUSER queue-user on >queue-password ~queue:* +@list +``` + +### Schritt 2: Code anpassen + +```php +// Schrittweise Migration: +// 1. Code unterst?tzt beide Methoden (Backward Compatible) +// 2. Neue Connections verwenden ACL +// 3. Alte Connections k?nnen weiterhin Legacy Auth verwenden +``` + +### Schritt 3: Testing + +```php +// Test mit verschiedenen Usern +// Verifizieren dass Isolation funktioniert +// Verifizieren dass Legacy Auth noch funktioniert +``` + +## Zusammenfassung + +| Aspekt | Legacy Auth | Redis ACL | +|--------|-------------|-----------| +| **Benutzer** | 1 (implizit) | Viele (explizit) | +| **Passwort** | 1 Passwort f?r alle | Passwort pro User | +| **Berechtigungen** | Alles oder nichts | Feingranular | +| **Key-Isolation** | Nein | Ja (Patterns) | +| **Befehl-Kontrolle** | Nein | Ja (pro Command) | +| **Komplexit?t** | Niedrig | Mittel-Hoch | +| **Sicherheit** | Basis | Hoch | +| **Verwaltung** | Einfach | Komplexer | + +## Empfehlung f?r unser Framework + +**Aktuell (Staging):** +- Legacy Auth ist ausreichend +- Einfacher zu verwalten +- Keine Multi-Tenant-Anforderungen + +**Zukunft (Production):** +- ACL k?nnte sinnvoll sein f?r: + - Bessere Isolation zwischen Cache/Queue/Session + - Compliance-Anforderungen + - Multi-Tenant-Szenarien + +**Code-Vorbereitung:** +- Code sollte beide Methoden unterst?tzen (Backward Compatible) +- `REDIS_USERNAME` optional machen +- Wenn `username` gesetzt ? ACL Auth +- Wenn nur `password` ? Legacy Auth + +## Weitere Ressourcen + +- [Redis ACL Documentation](https://redis.io/docs/management/security/acl/) +- [Redis ACL Commands](https://redis.io/commands/acl/) +- [Redis Security Best Practices](https://redis.io/docs/management/security/) diff --git a/docs/REDIS_AUTHENTICATION_ANALYSIS.md b/docs/REDIS_AUTHENTICATION_ANALYSIS.md new file mode 100644 index 00000000..8543095c --- /dev/null +++ b/docs/REDIS_AUTHENTICATION_ANALYSIS.md @@ -0,0 +1,364 @@ +# Redis Authentication Problem Analysis & Refactoring Plan + +## Problem Description + +**Fehlermeldung:** +``` +Direct Redis connection failed: Failed to connect to Redis (cache): WRONGPASS invalid username-password pair or user is disabled. +with Host: staging-redis and Password: gRUFGtFFthCGFHYjZ7o9yuiDRJ0JdG4Syiv1fVPb544= +``` + +## Root Cause Analysis + +### Identified Issues + +1. **Password Whitespace/Newline Problem** + - Das Passwort wird aus Docker Secrets geladen (`/run/secrets/redis_password`) + - `DockerSecretsResolver` verwendet `trim()`, aber m?glicherweise gibt es unsichtbare Zeichen (z.B. `\r\n`, `\n`, Tabs) + - Redis ist sehr strikt bei Passwort-?bereinstimmungen + +2. **Fehlende Passwort-Validierung** + - Keine Validierung des Passworts vor der Verwendung + - Keine Sanitization au?erhalb von `trim()` + +3. **Unzureichende Fehlerbehandlung** + - Fehlermeldung enth?lt das vollst?ndige Passwort (Security Risk!) + - Keine Unterscheidung zwischen verschiedenen Auth-Fehlertypen + - Keine detaillierte Diagnose-Informationen + +4. **Keine Redis ACL Unterst?tzung** + - Aktuell nur Legacy Passwort-Auth (`requirepass`) + - Redis 6+ unterst?tzt ACL mit Username/Password + - Code ist nicht f?r zuk?nftige ACL-Nutzung vorbereitet + +5. **Fehlende Passwort-Normalisierung** + - Verschiedene Whitespace-Zeichen werden nicht normalisiert + - Keine Behandlung von UTF-8 BOM oder anderen Steuerzeichen + +## Current Implementation Analysis + +### Code Flow + +``` +Environment::get(REDIS_PASSWORD) + ? +DockerSecretsResolver::resolve() + ? +file_get_contents() + trim() + ? +RedisConfig::fromEnvironment() + ? +RedisConnection::connect() + ? +$client->auth($password) ? WRONGPASS Fehler hier +``` + +### Problematic Code Sections + +**1. DockerSecretsResolver.php (Zeile 104)** +```php +$result = trim($content); +``` +- Problem: Entfernt nur Whitespace am Anfang/Ende +- Fehlt: Normalisierung von `\r\n` zu `\n`, Entfernung von unsichtbaren Zeichen + +**2. RedisConnection.php (Zeile 92-95)** +```php +if ($this->config->password) { + if (! $this->client->auth($this->config->password)) { + throw new RedisConnectionException("Redis authentication failed"); + } +} +``` +- Problem: Generische Fehlermeldung ohne Details +- Fehlt: Unterscheidung zwischen verschiedenen Auth-Fehlern +- Fehlt: Passwort-Validierung vor Auth-Versuch + +**3. RedisConnection.php (Zeile 115)** +```php +throw new RedisConnectionException( + "Failed to connect to Redis ({$this->name}): " . $e->getMessage() . " with Host: {$this->config->host} and Password: {$this->config->password}", + previous: $e +); +``` +- **KRITISCH**: Passwort wird in Exception-Message ausgegeben (Security Risk!) +- Passwort sollte niemals in Logs/Exceptions erscheinen + +## Refactoring Vorschl?ge + +### 1. Passwort-Normalisierung verbessern + +**Problem:** `trim()` ist unzureichend f?r Docker Secrets + +**L?sung:** Erweiterte Normalisierung in `DockerSecretsResolver` + +```php +private function normalizeSecret(string $content): string +{ + // Entferne alle Whitespace-Zeichen am Anfang und Ende + $normalized = trim($content); + + // Entferne unsichtbare Steuerzeichen (au?er normale Zeichen) + $normalized = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $normalized); + + // Normalisiere Line Endings (falls vorhanden) + $normalized = str_replace(["\r\n", "\r"], "", $normalized); + + // Entferne UTF-8 BOM falls vorhanden + if (str_starts_with($normalized, "\xEF\xBB\xBF")) { + $normalized = substr($normalized, 3); + } + + return $normalized; +} +``` + +### 2. Passwort-Validierung hinzuf?gen + +**Problem:** Keine Validierung vor Verwendung + +**L?sung:** Validierung in `RedisConfig` + +```php +private function validatePassword(?string $password): ?string +{ + if ($password === null || $password === '') { + return null; + } + + // Warnung f?r sehr kurze Passw?rter + if (strlen($password) < 8) { + // Log warning, aber nicht blockieren (kann gewollt sein) + } + + // Warnung f?r Whitespace in der Mitte (nur trimmen, nicht blockieren) + $trimmed = trim($password); + if ($trimmed !== $password) { + // Log warning: Password hatte Whitespace am Anfang/Ende + } + + return $trimmed !== '' ? $trimmed : null; +} +``` + +### 3. Sicherheit: Passwort aus Exceptions entfernen + +**Problem:** Passwort erscheint in Exception-Messages + +**L?sung:** Maskiertes Passwort in Fehlermeldungen + +```php +private function maskPassword(?string $password): string +{ + if ($password === null || $password === '') { + return '(not set)'; + } + + $length = strlen($password); + if ($length <= 4) { + return str_repeat('*', $length); + } + + return substr($password, 0, 2) . str_repeat('*', $length - 4) . substr($password, -2); +} +``` + +### 4. Verbesserte Fehlerbehandlung + +**Problem:** Generische Fehlermeldungen ohne Kontext + +**L?sung:** Detaillierte Exception mit diagnostischen Informationen + +```php +private function authenticate(): void +{ + if (!$this->config->password) { + return; // Keine Authentifizierung erforderlich + } + + try { + $authResult = $this->client->auth($this->config->password); + if (!$authResult) { + throw new RedisConnectionException( + "Redis authentication failed for connection '{$this->name}'", + context: $this->createAuthDiagnostics() + ); + } + } catch (RedisException $e) { + $message = $e->getMessage(); + + // Spezifische Fehlermeldungen basierend auf Redis-Fehlercode + if (str_contains($message, 'WRONGPASS')) { + throw new RedisConnectionException( + "Redis authentication failed: Invalid password for connection '{$this->name}'", + context: $this->createAuthDiagnostics(), + previous: $e + ); + } + + // Andere Auth-Fehler + throw new RedisConnectionException( + "Redis authentication error for connection '{$this->name}': {$message}", + context: $this->createAuthDiagnostics(), + previous: $e + ); + } +} + +private function createAuthDiagnostics(): ExceptionContext +{ + $context = ExceptionContext::forOperation('Redis Authentication'); + + $context->add('connection_name', $this->name); + $context->add('host', $this->config->host); + $context->add('port', $this->config->port); + $context->add('database', $this->config->database); + $context->add('password_set', $this->config->password !== null); + $context->add('password_length', $this->config->password ? strlen($this->config->password) : 0); + // KEIN Passwort in Context! + + return $context; +} +``` + +### 5. Redis ACL Unterst?tzung (Optional, f?r Zukunft) + +**Problem:** Keine Unterst?tzung f?r Redis 6+ ACL + +**L?sung:** Optionale Username/Password Auth + +```php +final readonly class RedisConfig +{ + private function __construct( + public string $host = 'redis', + public int $port = 6379, + public ?string $password = null, + public ?string $username = null, // NEU f?r ACL + // ... rest + ) {} + + public static function fromEnvironment(Environment $env): self + { + return new self( + // ... + password: $env->get(EnvKey::REDIS_PASSWORD, null), + username: $env->get(EnvKey::REDIS_USERNAME, null), // NEU + // ... + ); + } +} + +// In RedisConnection.php +private function authenticate(): void +{ + if (!$this->config->password) { + return; + } + + // Redis 6+ ACL: auth(username, password) + if ($this->config->username) { + $authResult = $this->client->auth($this->config->username, $this->config->password); + } else { + // Legacy: auth(password) + $authResult = $this->client->auth($this->config->password); + } + + if (!$authResult) { + throw new RedisConnectionException(/* ... */); + } +} +``` + +### 6. EnvKey Enum erweitern + +**L?sung:** Optional `REDIS_USERNAME` f?r zuk?nftige ACL-Nutzung + +```php +enum EnvKey: string +{ + // ... existing ... + case REDIS_HOST = 'REDIS_HOST'; + case REDIS_PORT = 'REDIS_PORT'; + case REDIS_PASSWORD = 'REDIS_PASSWORD'; + case REDIS_USERNAME = 'REDIS_USERNAME'; // NEU (optional) + case REDIS_PREFIX = 'REDIS_PREFIX'; +} +``` + +## Implementierungsplan + +### Phase 1: Kritische Sicherheitsfixes (SOFORT) + +1. ? **Passwort aus Exception-Messages entfernen** + - `RedisConnection.php` Zeile 115: Maskiertes Passwort verwenden + - Security Hotfix + +2. ? **Verbesserte Passwort-Normalisierung** + - `DockerSecretsResolver.php`: Erweiterte `normalizeSecret()` Methode + - Behebt wahrscheinlich das Hauptproblem + +### Phase 2: Robustheit (HOCH) + +3. ? **Passwort-Validierung** + - `RedisConfig`: Validierung und Sanitization + - Warnungen f?r problematische Passw?rter + +4. ? **Verbesserte Fehlerbehandlung** + - Spezifische Exception-Messages + - Diagnostische Informationen (ohne Passwort!) + +### Phase 3: Zukunftssicherheit (MITTEL) + +5. ?? **Redis ACL Unterst?tzung** (Optional) + - Nur wenn Redis ACL verwendet werden soll + - Backward-kompatibel mit Legacy Auth + +## Priorisierung + +| Priorit?t | Task | Impact | Effort | +|-----------|------|--------|--------| +| ?? KRITISCH | Passwort aus Exceptions entfernen | Security | Low | +| ?? KRITISCH | Passwort-Normalisierung verbessern | Fixes Bug | Low | +| ?? HOCH | Passwort-Validierung | Robustheit | Low | +| ?? HOCH | Verbesserte Fehlerbehandlung | Debugging | Medium | +| ?? MITTEL | Redis ACL Unterst?tzung | Zukunft | Medium | + +## Testing Recommendations + +1. **Unit Tests f?r Passwort-Normalisierung** + ```php + test('normalizes password with newlines', function () { + $resolver = new DockerSecretsResolver(); + // Test with \r\n, \n, \r, etc. + }); + ``` + +2. **Integration Tests f?r Redis Auth** + ```php + test('connects with password from Docker Secret', function () { + // Test with actual Redis instance + }); + ``` + +3. **Security Tests** + ```php + test('does not expose password in exception messages', function () { + // Verify password is masked + }); + ``` + +## Expected Outcomes + +Nach Implementierung: +- ? Passwort wird korrekt normalisiert (Whitespace entfernt) +- ? Keine Passw?rter in Logs/Exceptions +- ? Bessere Fehlermeldungen f?r Debugging +- ? Robustere Passwort-Behandlung +- ? Vorbereitung f?r Redis ACL (optional) + +## Notes + +- Das Hauptproblem ist wahrscheinlich **Whitespace/Newlines im Passwort** +- Die Security-Probleme (Passwort in Exceptions) sollten sofort behoben werden +- ACL-Unterst?tzung ist optional, aber gut f?r Zukunftssicherheit diff --git a/src/Framework/Discovery/Exceptions/DiscoveryException.php b/src/Framework/Discovery/Exceptions/DiscoveryException.php index 6361d318..a693f93f 100644 --- a/src/Framework/Discovery/Exceptions/DiscoveryException.php +++ b/src/Framework/Discovery/Exceptions/DiscoveryException.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace App\Framework\Discovery\Exceptions; -use App\Framework\Exception\Core\DiscoveryErrorCode; -use App\Framework\Exception\Core\FileSystemErrorCode; use App\Framework\Exception\ErrorCode; use App\Framework\Exception\ExceptionContext; use App\Framework\Exception\FrameworkException; diff --git a/src/Framework/Discovery/Processing/FileStreamProcessor.php b/src/Framework/Discovery/Processing/FileStreamProcessor.php index 1cd01ee2..7752f03d 100644 --- a/src/Framework/Discovery/Processing/FileStreamProcessor.php +++ b/src/Framework/Discovery/Processing/FileStreamProcessor.php @@ -68,7 +68,7 @@ final readonly class FileStreamProcessor } catch (\Throwable $e) { // Only log errors, not every processed file $this->logger?->warning( - "Failed to process file {$file->getPath()->toString()}: {$e->getMessage()}" + "Failed to process file {$file->getPath()->toString()}: {$e->getMessage()} in FileStreamProcessor" ); } finally { // Always cleanup after processing a file diff --git a/src/Framework/Filesystem/Exceptions/FilePermissionException.php b/src/Framework/Filesystem/Exceptions/FilePermissionException.php index 3cc7864a..3349082f 100644 --- a/src/Framework/Filesystem/Exceptions/FilePermissionException.php +++ b/src/Framework/Filesystem/Exceptions/FilePermissionException.php @@ -6,25 +6,43 @@ namespace App\Framework\Filesystem\Exceptions; use App\Framework\Exception\Core\FilesystemErrorCode; use App\Framework\Exception\ExceptionContext; +use App\Framework\Filesystem\PermissionChecker; +use App\Framework\Filesystem\ValueObjects\FileOwnership; +use App\Framework\Filesystem\ValueObjects\ProcessUser; final class FilePermissionException extends FilesystemException { public function __construct( string $path, string $operation = 'access', - ?string $reason = null + ?string $reason = null, + ?array $diagnosticInfo = null, + ?ProcessUser $currentUser = null, + ?FileOwnership $fileOwnership = null, + ?FileOwnership $parentOwnership = null ) { - $message = "Permission denied for {$operation} on file: {$path}"; - - if ($reason) { - $message .= " ({$reason})"; - } + $message = $this->buildMessage($path, $operation, $reason, $diagnosticInfo, $currentUser, $fileOwnership, $parentOwnership); $context = ExceptionContext::forOperation('file.permission', 'filesystem') ->withData([ 'path' => $path, 'operation' => $operation, 'reason' => $reason, + ]) + ->withDebug([ + 'file_permissions' => $diagnosticInfo['permissions'] ?? null, + 'file_owner' => $diagnosticInfo['owner'] ?? null, + 'file_group' => $diagnosticInfo['group'] ?? null, + 'file_exists' => $diagnosticInfo['exists'] ?? null, + 'is_file' => $diagnosticInfo['is_file'] ?? null, + 'is_dir' => $diagnosticInfo['is_dir'] ?? null, + 'parent_dir' => $diagnosticInfo['parent_dir'] ?? null, + 'parent_writable' => $diagnosticInfo['parent_writable'] ?? null, + ]) + ->withMetadata([ + 'current_user' => $currentUser?->toArray(), + 'file_ownership' => $fileOwnership?->toArray(), + 'parent_ownership' => $parentOwnership?->toArray(), ]); parent::__construct( @@ -34,23 +52,147 @@ final class FilePermissionException extends FilesystemException ); } - public static function read(string $path, ?string $reason = null): self + /** + * Build detailed error message with permission and user information + */ + private function buildMessage( + string $path, + string $operation, + ?string $reason, + ?array $diagnosticInfo, + ?ProcessUser $currentUser, + ?FileOwnership $fileOwnership, + ?FileOwnership $parentOwnership + ): string { + $message = "Permission denied for {$operation} on file: {$path}"; + + if ($reason) { + $message .= " ({$reason})"; + } + + // Add file ownership information + if ($fileOwnership !== null) { + $message .= "\n File owner: {$fileOwnership->owner}, group: {$fileOwnership->group}"; + } elseif (isset($diagnosticInfo['owner'], $diagnosticInfo['group'])) { + $message .= sprintf( + "\n File owner: %s, group: %s", + $diagnosticInfo['owner'], + $diagnosticInfo['group'] + ); + } + + // Add file permissions + if (isset($diagnosticInfo['permissions'])) { + $message .= ", permissions: {$diagnosticInfo['permissions']}"; + } + + // Add parent directory information + if (isset($diagnosticInfo['parent_dir'])) { + $parentInfo = []; + if ($parentOwnership !== null) { + $parentInfo[] = "owner: {$parentOwnership->owner}"; + $parentInfo[] = "group: {$parentOwnership->group}"; + } + if (isset($diagnosticInfo['parent_writable'])) { + $parentInfo[] = 'writable: ' . ($diagnosticInfo['parent_writable'] ? 'yes' : 'no'); + } + + if (!empty($parentInfo)) { + $message .= "\n Parent directory: {$diagnosticInfo['parent_dir']} (" . implode(', ', $parentInfo) . ')'; + } + } + + // Add current process user information + if ($currentUser !== null) { + $message .= "\n Current process user: {$currentUser->toString()}"; + } + + return $message; + } + + /** + * Create exception with detailed permission information + */ + private static function createWithDetails( + string $path, + string $operation, + ?string $reason = null, + ?PermissionChecker $permissionChecker = null + ): self { + $diagnosticInfo = null; + $fileOwnership = null; + $parentOwnership = null; + + if ($permissionChecker !== null) { + try { + $diagnosticInfo = $permissionChecker->getDiagnosticInfo($path); + // Use resolved_path from diagnostic info if available + $resolvedPath = $diagnosticInfo['resolved_path'] ?? $path; + + // Get file ownership + if (file_exists($resolvedPath)) { + $fileOwnership = FileOwnership::fromPath($resolvedPath); + } + + // Get parent directory ownership + if (isset($diagnosticInfo['parent_dir'])) { + $parentDir = $diagnosticInfo['parent_dir']; + if (file_exists($parentDir)) { + $parentOwnership = FileOwnership::fromPath($parentDir); + } + } + } catch (\Throwable $e) { + // If diagnostic info collection fails, continue without it + $diagnosticInfo = null; + } + } + + $currentUser = ProcessUser::current(); + + return new self( + path: $path, + operation: $operation, + reason: $reason, + diagnosticInfo: $diagnosticInfo, + currentUser: $currentUser, + fileOwnership: $fileOwnership, + parentOwnership: $parentOwnership + ); + } + + public static function read(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self { + if ($permissionChecker !== null) { + return self::createWithDetails($path, 'read', $reason, $permissionChecker); + } + return new self($path, 'read', $reason); } - public static function write(string $path, ?string $reason = null): self + public static function write(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self { + if ($permissionChecker !== null) { + return self::createWithDetails($path, 'write', $reason, $permissionChecker); + } + return new self($path, 'write', $reason); } - public static function delete(string $path, ?string $reason = null): self + public static function delete(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self { + if ($permissionChecker !== null) { + return self::createWithDetails($path, 'delete', $reason, $permissionChecker); + } + return new self($path, 'delete', $reason); } - public static function createDirectory(string $path, ?string $reason = null): self + public static function createDirectory(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self { + if ($permissionChecker !== null) { + return self::createWithDetails($path, 'create directory', $reason, $permissionChecker); + } + return new self($path, 'create directory', $reason); } } diff --git a/src/Framework/Filesystem/FileStorage.php b/src/Framework/Filesystem/FileStorage.php index fae53a8d..a42f3444 100644 --- a/src/Framework/Filesystem/FileStorage.php +++ b/src/Framework/Filesystem/FileStorage.php @@ -115,14 +115,14 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt } if (! is_readable($resolvedPath)) { - throw FilePermissionException::read($path, 'File is not readable'); + throw FilePermissionException::read($path, 'File is not readable', $this->permissions); } $content = @file_get_contents($resolvedPath); if ($content === false) { $error = error_get_last(); if ($error && str_contains($error['message'], 'Permission denied')) { - throw FilePermissionException::read($path, $error['message']); + throw FilePermissionException::read($path, $error['message'], $this->permissions); } throw new FileReadException($path); @@ -156,24 +156,24 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt if (! @mkdir($dir, 0777, true) && ! is_dir($dir)) { $error = error_get_last(); if ($error && str_contains($error['message'], 'Permission denied')) { - throw FilePermissionException::createDirectory($dir, $error['message']); + throw FilePermissionException::createDirectory($dir, $error['message'], $this->permissions); } throw new DirectoryCreateException($dir); } } elseif (! is_writable($dir)) { - throw FilePermissionException::write($path, 'Directory is not writable: ' . $dir); + throw FilePermissionException::write($path, 'Directory is not writable: ' . $dir, $this->permissions); } // Prüfe File-Permissions wenn Datei bereits existiert if (is_file($resolvedPath) && ! is_writable($resolvedPath)) { - throw FilePermissionException::write($path, 'File is not writable'); + throw FilePermissionException::write($path, 'File is not writable', $this->permissions); } if (@file_put_contents($resolvedPath, $content) === false) { $error = error_get_last(); if ($error && str_contains($error['message'], 'Permission denied')) { - throw FilePermissionException::write($path, $error['message']); + throw FilePermissionException::write($path, $error['message'], $this->permissions); } throw new FileWriteException($path); @@ -229,7 +229,7 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt if (! @unlink($resolvedPath)) { $error = error_get_last(); if ($error && str_contains($error['message'], 'Permission denied')) { - throw FilePermissionException::delete($path, $error['message']); + throw FilePermissionException::delete($path, $error['message'], $this->permissions); } throw new FileDeleteException($path); @@ -386,13 +386,13 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt // Prüfe Parent-Directory Permissions $parentDir = dirname($resolvedPath); if (is_dir($parentDir) && ! is_writable($parentDir)) { - throw FilePermissionException::createDirectory($path, 'Parent directory is not writable: ' . $parentDir); + throw FilePermissionException::createDirectory($path, 'Parent directory is not writable: ' . $parentDir, $this->permissions); } if (! @mkdir($resolvedPath, $permissions, $recursive) && ! is_dir($resolvedPath)) { $error = error_get_last(); if ($error && str_contains($error['message'], 'Permission denied')) { - throw FilePermissionException::createDirectory($path, $error['message']); + throw FilePermissionException::createDirectory($path, $error['message'], $this->permissions); } throw new DirectoryCreateException($path); diff --git a/src/Framework/Filesystem/ValueObjects/FileOwnership.php b/src/Framework/Filesystem/ValueObjects/FileOwnership.php new file mode 100644 index 00000000..798d21e8 --- /dev/null +++ b/src/Framework/Filesystem/ValueObjects/FileOwnership.php @@ -0,0 +1,116 @@ +ownerUid === $processUser->uid; + } + + /** + * Check if ownership matches by user name + */ + public function matchesOwner(string $userName): bool + { + return $this->owner === $userName; + } + + /** + * Get string representation + */ + public function toString(): string + { + return "{$this->owner}:{$this->group} (uid: {$this->ownerUid}, gid: {$this->groupGid})"; + } + + /** + * Convert to array for logging/debugging + */ + public function toArray(): array + { + return [ + 'owner' => $this->owner, + 'group' => $this->group, + 'owner_uid' => $this->ownerUid, + 'group_gid' => $this->groupGid, + ]; + } +} diff --git a/src/Framework/Filesystem/ValueObjects/ProcessUser.php b/src/Framework/Filesystem/ValueObjects/ProcessUser.php new file mode 100644 index 00000000..8ecc0dd4 --- /dev/null +++ b/src/Framework/Filesystem/ValueObjects/ProcessUser.php @@ -0,0 +1,108 @@ +uid === 0; + } + + /** + * Check if this user matches another user by name + */ + public function matchesName(string $userName): bool + { + return $this->name === $userName; + } + + /** + * Check if this user matches another user by UID + */ + public function matchesUid(int $uid): bool + { + return $this->uid === $uid; + } + + /** + * Get string representation + */ + public function toString(): string + { + return "{$this->name} (uid: {$this->uid}, gid: {$this->gid}, group: {$this->group})"; + } + + /** + * Convert to array for logging/debugging + */ + public function toArray(): array + { + return [ + 'uid' => $this->uid, + 'gid' => $this->gid, + 'name' => $this->name, + 'group' => $this->group, + ]; + } +}