From 3917a11db0341cae967bfba0331b82857393e750 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Wed, 15 Jul 2026 20:07:10 +0200 Subject: [PATCH] M8: Mandanten-Isolation und Rollenmatrix automatisiert testen scripts/check-m8-tenant-isolation.php: legt zwei frische, isolierte Test-Mandanten mit je einem Teilnehmer, einer Ledger-Buchung, einem Hinweis und einem Audit-Log-Eintrag an und prueft 10 Faelle - Lesezugriffe (Teilnehmerlisten, Einzelabruf, letzte Buchungen, aktive Hinweise, Audit-Log) und Schreibzugriffe (Buchung, Zugangsvergabe, Storno) sind strikt auf den jeweils richtigen Mandanten beschraenkt. Raeumt sich selbst auf. 10/10 gruen. scripts/check-m8-role-matrix.php: legt einen Test-Mandanten mit je einem Nutzer pro Rolle an (owner/admin/treasurer/member/viewer), loggt sich per echtem HTTP-Request ein (manueller Cookie-Jar ueber file_get_contents, da diese PHP-Installation keine curl-Extension hat) und prueft alle elf rollen-geschuetzten Seiten gegen die erwartete Rollenliste. 55/55 gruen (5 Rollen x 11 Seiten) - bestaetigt, dass die Rollenpruefungen ueberall konsistent mit dem im Plan dokumentierten Rollenmodell durchgesetzt sind. Co-Authored-By: Claude Sonnet 5 --- docs/m8-haertung.md | 37 +++++- docs/saas-umstrukturierungsplan.md | 11 +- scripts/check-m8-role-matrix.php | 183 ++++++++++++++++++++++++++ scripts/check-m8-tenant-isolation.php | 144 ++++++++++++++++++++ 4 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 scripts/check-m8-role-matrix.php create mode 100644 scripts/check-m8-tenant-isolation.php diff --git a/docs/m8-haertung.md b/docs/m8-haertung.md index b26c836..7dbd0c6 100644 --- a/docs/m8-haertung.md +++ b/docs/m8-haertung.md @@ -91,18 +91,51 @@ mailversenden.php eine Einstellungsänderung im UI-Protokoll mit korrektem Nutzernamen erscheint. Testdaten anschließend entfernt. +## Mandanten-Isolation (automatisiert) + +Umgesetzte Datei: `scripts/check-m8-tenant-isolation.php` + +Legt zwei frische, isolierte Test-Mandanten mit je einem Teilnehmer, einer +Ledger-Buchung, einem Hinweis und einem Audit-Log-Eintrag an und prüft +10 Fälle: Teilnehmerlisten, Einzelabruf, letzte Buchungen, aktive Hinweise, +Schreibversuche (Buchung, Zugangsvergabe, Storno) und Audit-Log sind +strikt auf den jeweils richtigen Mandanten beschränkt; ein Zugriff über den +falschen Mandanten liefert nichts beziehungsweise schlägt sauber fehl statt +fremde Daten zurückzugeben. Räumt die Testdaten am Ende selbst auf. + +Ergebnis: grün mit 10 Assertions. + +## Rollenmatrix (automatisiert) + +Umgesetzte Datei: `scripts/check-m8-role-matrix.php` + +Legt einen Test-Mandanten mit je einem Nutzer pro Rolle (`owner`, `admin`, +`treasurer`, `member`, `viewer`) an, loggt sich für jede Rolle per echtem +HTTP-Request ein (manueller Cookie-Jar über `file_get_contents`, da diese +PHP-Installation keine curl-Extension hat) und ruft alle rollen-geschützten +Seiten auf: `kaffeeliste.php`, `mitarbeiterverwalten.php`, `hinweise.php`, +`stricheintragen.php`, `einzahlung.php`, `letzteneintraege.php`, +`csvupload.php`, `exportKaffeeliste.php`, `mailversenden.php`, +`jahresauswertung.php`, `mandant-einstellungen.php`. Für jede +Rolle-Seite-Kombination wird geprüft, ob der tatsächliche Zugriff (anhand +der „Kein Zugriff"/„keine Berechtigung"-Marker in der Antwort) mit der +erwarteten Rollenliste übereinstimmt. + +Ergebnis: grün mit 55 Assertions (5 Rollen × 11 Seiten). Räumt die +Testdaten am Ende selbst auf. + ## Prüfstatus - Golden Master: grün mit 104 Assertions. - M4 Ledger-Migration: grün mit 73 Assertions. - M3 Settings-Flow: grün mit 13 Assertions. - HTTP-Smoke: grün mit 26 geprüften Seiten. +- M8 Mandanten-Isolation: grün mit 10 Assertions. +- M8 Rollenmatrix: grün mit 55 Assertions. ## Noch offen in M8 - Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles). -- Mandanten-Isolation automatisiert testen. -- Rollenmatrix automatisiert testen. - Datenexport pro Mandant. - Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden. - Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 617ec8d..cb85f37 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -308,7 +308,7 @@ Nicht tun: | M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen | | M6 | Betriebsflows | Abgeschlossen: Import, Export, Mail und Jahresabschluss sind auditierbar | | M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen | -| M8 | Härtung | Gestartet: Security-Headers, Rate-Limits und Audit-Log stehen; Isolation/Rollenmatrix-Tests, Datenexport, Löschung und Betrieb offen | +| M8 | Härtung | Weit fortgeschritten: Security-Headers, Rate-Limits, Audit-Log, Mandanten-Isolations- und Rollenmatrix-Tests stehen; Datenexport, Löschung und Betriebs-Doku offen | | M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only | ### M0: Planungs- und Sicherheitsbaseline @@ -652,8 +652,13 @@ Schritte: - Rate-Limits und Security Headers: erledigt. Globale Security-Headers über `app/bootstrap.php` (ohne CSP, siehe `docs/m8-haertung.md`), DB-gestützte Rate-Limits für Login, Registrierung und Passwort-Reset. -- Mandanten-Isolation testen. -- Rollenmatrix testen. +- Mandanten-Isolation testen: erledigt. + `scripts/check-m8-tenant-isolation.php` legt zwei isolierte Test-Mandanten + an und prüft Lese- und Schreibzugriffe über den jeweils falschen + Mandanten (10 Assertions, grün). +- Rollenmatrix testen: erledigt. + `scripts/check-m8-role-matrix.php` prüft alle fünf Rollen gegen alle + rollen-geschützten Seiten per echtem HTTP-Login (55 Assertions, grün). Ergebnis: diff --git a/scripts/check-m8-role-matrix.php b/scripts/check-m8-role-matrix.php new file mode 100644 index 0000000..a8e4db1 --- /dev/null +++ b/scripts/check-m8-role-matrix.php @@ -0,0 +1,183 @@ + Rollen, die Zugriff haben sollen. Alle anderen Rollen muessen +// abgewiesen werden. Muss mit den saas_user_has_role()-Aufrufen in den +// jeweiligen Dateien uebereinstimmen. +$pages = [ + 'kaffeeliste.php' => ['owner', 'admin', 'treasurer'], + 'mitarbeiterverwalten.php' => ['owner', 'admin'], + 'hinweise.php' => ['owner', 'admin'], + 'stricheintragen.php' => ['owner', 'admin', 'treasurer'], + 'einzahlung.php' => ['owner', 'admin', 'treasurer'], + 'letzteneintraege.php' => ['owner', 'admin', 'treasurer'], + 'csvupload.php' => ['owner', 'admin', 'treasurer'], + 'exportKaffeeliste.php' => ['owner', 'admin', 'treasurer'], + 'mailversenden.php' => ['owner', 'admin', 'treasurer'], + 'jahresauswertung.php' => ['owner', 'admin', 'treasurer'], + 'mandant-einstellungen.php' => ['owner', 'admin'], +]; + +$denialMarkers = ['kein zugriff', 'keine zugang', 'keine berechtigung']; + +function m8rm_looks_denied(string $body): bool +{ + global $denialMarkers; + $lower = strtolower($body); + foreach ($denialMarkers as $marker) { + if (str_contains($lower, $marker)) { + return true; + } + } + + return false; +} + +/** + * Minimaler manueller Cookie-Jar auf Basis von file_get_contents, da diese + * PHP-Installation keine curl-Extension hat. $cookies wird per Referenz + * aktualisiert, sobald die Antwort Set-Cookie-Header enthaelt (insbesondere + * nach dem Login, der die Session-ID per session_regenerate_id() erneuert). + * + * @param array $cookies + * @return array{status:int, body:string} + */ +function m8rm_request(string $url, array &$cookies, string $method = 'GET', ?string $postBody = null): array +{ + $cookieHeader = ''; + if ($cookies !== []) { + $pairs = []; + foreach ($cookies as $name => $value) { + $pairs[] = "{$name}={$value}"; + } + $cookieHeader = 'Cookie: ' . implode('; ', $pairs) . "\r\n"; + } + + $headers = "User-Agent: KaffeelisteRoleMatrix/1.0\r\n" . $cookieHeader; + if ($postBody !== null) { + $headers .= "Content-Type: application/x-www-form-urlencoded\r\n"; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => $headers, + 'content' => $postBody, + 'timeout' => 15, + 'ignore_errors' => true, + 'follow_location' => 0, + ], + ]); + + $body = @file_get_contents($url, false, $context); + $responseHeaders = $http_response_header ?? []; + $status = 0; + + foreach ($responseHeaders as $header) { + if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) { + $status = (int)$m[1]; + } + if (preg_match('/^Set-Cookie:\s*([^=;]+)=([^;]+)/i', $header, $m) === 1) { + $cookies[$m[1]] = $m[2]; + } + } + + return ['status' => $status, 'body' => (string)$body]; +} + +$tenantId = null; +$userIds = []; + +try { + $stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)'); + $stmt->execute(["rolematrix-{$suffix}", 'Rollenmatrix Tenant', 'active']); + $tenantId = (int)$pdo->lastInsertId(); + $pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantId]); + + foreach ($roles as $role) { + $email = "rolematrix-{$role}-{$suffix}@test.local"; + $stmt = $pdo->prepare( + 'INSERT INTO users (email, email_norm, display_name, password_hash, status) VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([$email, $email, "Rolematrix {$role}", password_hash($password, PASSWORD_DEFAULT), 'active']); + $userId = (int)$pdo->lastInsertId(); + $userIds[$role] = $userId; + + $pdo->prepare( + 'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at) VALUES (?, ?, ?, ?, NOW())' + )->execute([$tenantId, $userId, $role, 'active']); + + $pdo->prepare( + 'INSERT INTO participants (tenant_id, user_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, ?, 1)' + )->execute([$tenantId, $userId, "Rolematrix {$role}", $email, $email]); + } +} catch (Throwable $e) { + fwrite(STDERR, "M8 role matrix setup failed: {$e->getMessage()}\n"); + exit(1); +} + +$failures = []; +$passes = 0; + +foreach ($roles as $role) { + $cookies = []; + + $loginPage = m8rm_request("{$baseUrl}/login.php", $cookies); + preg_match('/name="csrf_token" value="([^"]+)"/', $loginPage['body'], $matches); + $csrf = $matches[1] ?? ''; + + $postBody = http_build_query([ + 'csrf_token' => $csrf, + 'email' => "rolematrix-{$role}-{$suffix}@test.local", + 'password' => $password, + 'tenant_slug' => "rolematrix-{$suffix}", + ]); + m8rm_request("{$baseUrl}/login.php", $cookies, 'POST', $postBody); + + foreach ($pages as $page => $allowedRoles) { + $response = m8rm_request("{$baseUrl}/{$page}", $cookies); + + $shouldHaveAccess = in_array($role, $allowedRoles, true); + $looksGranted = !m8rm_looks_denied($response['body']); + + $label = "{$role} auf {$page}"; + if ($shouldHaveAccess === $looksGranted) { + $passes++; + echo "PASS {$label}\n"; + } else { + $failures[] = $shouldHaveAccess + ? "{$label}: Zugriff erwartet, aber verweigert" + : "{$label}: Zugriff verweigert erwartet, aber gewährt"; + echo "FAIL {$label}\n"; + } + } +} + +// Aufraeumen +$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]); +$pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]); +foreach ($userIds as $uid) { + $pdo->prepare('DELETE FROM user_auth_tokens WHERE user_id = ?')->execute([$uid]); + $pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$uid]); +} +$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]); +$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); + +if ($failures !== []) { + echo "\nM8 role matrix check failed with " . count($failures) . " failure(s):\n"; + foreach ($failures as $failure) { + echo "- {$failure}\n"; + } + exit(1); +} + +echo "\nM8 role matrix check passed with {$passes} assertions.\n"; diff --git a/scripts/check-m8-tenant-isolation.php b/scripts/check-m8-tenant-isolation.php new file mode 100644 index 0000000..9412d86 --- /dev/null +++ b/scripts/check-m8-tenant-isolation.php @@ -0,0 +1,144 @@ +prepare('DELETE FROM audit_log WHERE tenant_id = ?')->execute([$tid]); + $pdo->prepare('DELETE FROM notices WHERE tenant_id = ?')->execute([$tid]); + $pdo->prepare('DELETE FROM ledger_entries WHERE tenant_id = ?')->execute([$tid]); + $pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tid]); + $pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tid]); + $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tid]); + } +} + +$pdo = dev_pdo(); +$failures = []; +$passes = 0; +$suffix = bin2hex(random_bytes(4)); +$tenantIds = []; + +try { + // Zwei isolierte Test-Mandanten mit je einem Teilnehmer, einer Buchung, + // einem Hinweis und einem Audit-Log-Eintrag anlegen. + $participantIds = []; + foreach (['a', 'b'] as $key) { + $stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)'); + $stmt->execute(["isolation-{$key}-{$suffix}", "Isolation Tenant {$key}", 'active']); + $tenantIds[$key] = (int)$pdo->lastInsertId(); + + $pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantIds[$key]]); + + $stmt = $pdo->prepare( + 'INSERT INTO participants (tenant_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, 1)' + ); + $email = "isolation-{$key}-{$suffix}@test.local"; + $stmt->execute([$tenantIds[$key], "Isolation {$key}", $email, $email]); + $participantIds[$key] = (int)$pdo->lastInsertId(); + + ledger_record_payment($pdo, $tenantIds[$key], $participantIds[$key], 1000, 'isolation_test'); + notices_create($pdo, $tenantIds[$key], "Hinweis {$key}", date('Y-m-d H:i:s', time() + 3600), null); + app_audit_log($pdo, $tenantIds[$key], null, 'isolation_test.marker', 'test', null); + } + + $tenantA = $tenantIds['a']; + $tenantB = $tenantIds['b']; + $participantA = $participantIds['a']; + + // 1. Teilnehmerlisten sind strikt getrennt. + $summariesB = ledger_fetch_participant_summaries($pdo, $tenantB, ['active_only' => true]); + $namesB = array_column($summariesB, 'display_name'); + m8_assert('tenant B sieht nur eigenen Teilnehmer', in_array('Isolation b', $namesB, true) && !in_array('Isolation a', $namesB, true), $failures, $passes); + + // 2. Direkter Zugriff auf einen fremden Teilnehmer ueber den falschen Mandanten liefert nichts. + $crossSummary = ledger_fetch_participant_summary($pdo, $tenantB, $participantA); + m8_assert('fremder Teilnehmer ueber falschen Mandanten nicht lesbar', $crossSummary === null, $failures, $passes); + + // 3. Letzte Buchungen sind mandantenscoped. + $recentB = ledger_fetch_recent_entries($pdo, $tenantB, ['limit' => 50]); + $participantIdsInRecentB = array_column($recentB, 'participant_id'); + m8_assert('letzte Buchungen zeigen keinen fremden Teilnehmer', !in_array($participantA, $participantIdsInRecentB, true), $failures, $passes); + + // 4. Aktive Hinweise sind mandantenscoped. + $noticeB = notices_fetch_active($pdo, $tenantB); + m8_assert('aktiver Hinweis ist der eigene', $noticeB !== null && $noticeB['message'] === 'Hinweis b', $failures, $passes); + + // 5. Schreibversuch auf einen fremden Teilnehmer ueber den falschen Mandanten schlaegt fehl. + $crossWriteFailed = false; + try { + ledger_record_payment($pdo, $tenantB, $participantA, 500, 'isolation_test'); + } catch (Throwable $e) { + $crossWriteFailed = true; + } + m8_assert('Buchung auf fremden Teilnehmer ueber falschen Mandanten schlaegt fehl', $crossWriteFailed, $failures, $passes); + + // 6. Zugangsvergabe auf einen fremden Teilnehmer ueber den falschen Mandanten schlaegt fehl. + $grantResult = saas_grant_participant_access($pdo, $tenantB, $participantA, 'member'); + m8_assert('Zugangsvergabe auf fremden Teilnehmer schlaegt fehl', $grantResult['ok'] === false, $failures, $passes); + + // 7. Storno eines fremden Eintrags ueber den falschen Mandanten aendert nichts. + // ledger_void_entry_by_legacy_id() matcht ueber (tenant_id, legacy_table, + // legacy_id); dafuer braucht es einen Eintrag mit einer synthetischen + // Legacy-Referenz, unabhaengig von echten kl_*-Zeilen. + $pdo->prepare( + "UPDATE ledger_entries SET legacy_table = 'isolation_test', legacy_id = ? WHERE tenant_id = ? AND participant_id = ?" + )->execute([$participantA, $tenantA, $participantA]); + $voidedWrongTenant = ledger_void_entry_by_legacy_id($pdo, $tenantB, 'isolation_test', $participantA); + m8_assert('Storno ueber falschen Mandanten aendert nichts', $voidedWrongTenant === false, $failures, $passes); + $voidedRightTenant = ledger_void_entry_by_legacy_id($pdo, $tenantA, 'isolation_test', $participantA); + m8_assert('Storno ueber den richtigen Mandanten funktioniert', $voidedRightTenant === true, $failures, $passes); + + // 8. Audit-Log ist mandantenscoped. + $auditB = app_fetch_audit_log($pdo, $tenantB, 50); + $auditActionsB = array_column($auditB, 'action'); + m8_assert('Audit-Log von Mandant B enthaelt nur eigene Eintraege', in_array('isolation_test.marker', $auditActionsB, true) && count($auditB) === 1, $failures, $passes); + + // 9. Tenant-Settings sind getrennt (unterschiedliche IDs, kein Ueberlauf). + $settingsA = saas_fetch_tenant_settings($pdo, $tenantA); + $settingsB = saas_fetch_tenant_settings($pdo, $tenantB); + m8_assert('tenant_settings sind pro Mandant getrennt', $settingsA['tenant_id'] !== $settingsB['tenant_id'], $failures, $passes); +} catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + fwrite(STDERR, "M8 tenant isolation check failed to run: {$e->getMessage()}\n"); + m8_cleanup($pdo, $tenantIds); + exit(1); +} + +m8_cleanup($pdo, $tenantIds); + +if ($failures !== []) { + echo "\nM8 tenant isolation check failed with " . count($failures) . " failure(s):\n"; + foreach ($failures as $failure) { + echo "- {$failure}\n"; + } + exit(1); +} + +echo "\nM8 tenant isolation check passed with {$passes} assertions.\n";