Files
kaffeekasse-saas/scripts/check-m8-tenant-isolation.php
clemensandClaude Opus 4.8 eec7a2fef2 Legacy-Abbau Schritt 1: Dual-Write in die kl_*-Tabellen entfernt
Die Migration in participants/ledger_entries ist abgeschlossen: kein
Teilnehmer traegt noch eine legacy_mitarbeiter_id, kein Journaleintrag eine
legacy_table. Damit war der jeweils zweite Zweig ("Default-Mandant schreibt
zusaetzlich nach kl_Einzahlungen/kl_Kaffeeverbrauch/kl_Mitarbeiter") in
sechs Dateien nicht mehr erreichbar - er musste aber bei jeder Aenderung
mitgepflegt werden, zuletzt bei der Bemerkung fuer Einzahlungen.

Entfernt:
- Dual-Write beim Buchen: einzahlung.php, stricheintragen.php, index.php,
  jahresauswertung.php, app/imports.php, app/paypal-inbox.php
- Dual-Write in der Mitgliederverwaltung: ledger_create_participant,
  ledger_update_participant, ledger_set_participant_active,
  ledger_anonymize_participant
- die verwaisten Spiegelfunktionen ledger_mirror_legacy_payment,
  ledger_mirror_legacy_consumption und ledger_void_entry_by_legacy_id

Nebenbei behoben: kaffeeliste.php hat die Teilnehmer-Detailseite nur fuer
Mitglieder mit legacy_mitarbeiter_id verlinkt - die hat seit der Migration
niemand mehr, die Seite war also fuer alle unerreichbar. Verlinkt und
adressiert wird jetzt ueber participant_id; "user_id" bleibt als Alias
erhalten. Der Mandanten-Isolationstest prueft jetzt ledger_void_entry, also
den Pfad, den letzteneintraege.php tatsaechlich nutzt.

Pruefskripte unveraendert gegenueber der Baseline vor dem Umbau
(http-smoke 27/6, role-matrix 55/0); die Isolationspruefung steigt von
9 auf 11 PASS bei gleichem vorbestehendem Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:57:49 +02:00

150 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require __DIR__ . '/../app/ledger.php';
require __DIR__ . '/../app/saas-auth.php';
require __DIR__ . '/../app/notices.php';
require __DIR__ . '/../app/audit.php';
function m8_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
/**
* Some app functions (e.g. saas_grant_participant_access) manage their own
* PDO transaction, so this script cannot wrap everything in one outer
* transaction. Cleanup runs in a finally block instead.
*/
function m8_cleanup(PDO $pdo, array $tenantIds): void
{
foreach ($tenantIds as $tid) {
$pdo->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.
// Geprueft wird der echte Storno-Pfad ledger_void_entry(), den auch
// letzteneintraege.php nutzt.
$entryIdA = (int)$pdo->query(
"SELECT id FROM ledger_entries WHERE tenant_id = {$tenantA} AND participant_id = {$participantA} AND voided_at IS NULL LIMIT 1"
)->fetchColumn();
m8_assert('Testeintrag fuer Storno vorhanden', $entryIdA > 0, $failures, $passes);
$voidedWrongTenant = ledger_void_entry($pdo, $tenantB, $entryIdA);
m8_assert('Storno ueber falschen Mandanten aendert nichts', $voidedWrongTenant === false, $failures, $passes);
$stillActive = (string)$pdo->query("SELECT COUNT(*) FROM ledger_entries WHERE id = {$entryIdA} AND voided_at IS NULL")->fetchColumn();
m8_assert('fremder Eintrag ist nach abgelehntem Storno unveraendert', $stillActive === '1', $failures, $passes);
$voidedRightTenant = ledger_void_entry($pdo, $tenantA, $entryIdA);
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";