From 213dd10dbdbf42394d65fe6444fb84f8ab060ba6 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Sat, 22 Aug 2026 15:29:52 +0200 Subject: [PATCH] Produktiv- und Testtabellen per Prefix trennen --- app/database.php | 4 +- app/prefixed-pdo.php | 156 +++++++++++++++++++++++++ docs/betrieb-backup-monitoring.md | 9 +- docs/deployment.md | 55 ++++++--- docs/dev-mysql.md | 5 +- docs/m2-technical-foundation.md | 4 +- env.local.example.php | 3 + scripts/check-db-table-prefix.php | 96 +++++++++++++++ scripts/check-production-readiness.php | 19 ++- scripts/dev-db.php | 13 ++- scripts/migrate-table-prefix.php | 150 ++++++++++++++++++++++++ 11 files changed, 490 insertions(+), 24 deletions(-) create mode 100644 app/prefixed-pdo.php create mode 100644 scripts/check-db-table-prefix.php create mode 100644 scripts/migrate-table-prefix.php diff --git a/app/database.php b/app/database.php index 72b86d8..d33bf67 100644 --- a/app/database.php +++ b/app/database.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/bootstrap.php'; +require_once __DIR__ . '/prefixed-pdo.php'; function app_db_pdo(): PDO { @@ -23,11 +24,12 @@ function app_db_pdo(): PDO } $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database); - $pdo = new PDO($dsn, $user, $password ?? '', [ + $pdo = new AppPrefixedPDO($dsn, $user, $password ?? '', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => true, ]); + $pdo->setTablePrefix(app_db_table_prefix()); // DB-Session auf denselben Offset wie die (in bootstrap.php gepinnte) // PHP-Zeitzone setzen, damit MySQL NOW() und PHP-Zeitstempel diff --git a/app/prefixed-pdo.php b/app/prefixed-pdo.php new file mode 100644 index 0000000..f91b26c --- /dev/null +++ b/app/prefixed-pdo.php @@ -0,0 +1,156 @@ + + */ +function app_database_table_names(): array +{ + return [ + 'schema_migrations', + 'CoffeeSurveyVotedEmails', + 'CoffeeSurveyResponses', + 'dev_baseline_metadata', + 'kl_Kaffeeverbrauch', + 'kl_Einzahlungen', + 'kl_Mitarbeiter', + 'kl_hinweise', + 'kl_config', + 'payment_import_batches', + 'payment_import_rows', + 'stripe_webhook_events', + 'rate_limit_attempts', + 'tenant_memberships', + 'legal_acceptances', + 'outbound_emails', + 'user_auth_tokens', + 'tenant_settings', + 'platform_admins', + 'tenant_features', + 'paypal_payments', + 'ledger_entries', + 'legal_requests', + 'tenant_domains', + 'tenant_billing', + 'faq_entries', + 'participants', + 'audit_log', + 'notices', + 'tenants', + 'users', + ]; +} + +function app_validate_db_table_prefix(string $prefix): string +{ + $prefix = trim($prefix); + if ($prefix === '') { + return ''; + } + if (strlen($prefix) > 16 || preg_match('/^[a-z][a-z0-9_]*_$/', $prefix) !== 1) { + throw new RuntimeException( + 'DB_TABLE_PREFIX muss leer sein oder aus höchstens 16 Kleinbuchstaben, Ziffern und Unterstrichen bestehen und mit Unterstrich enden.' + ); + } + + return $prefix; +} + +function app_db_table_prefix(): string +{ + $prefix = app_validate_db_table_prefix((string)app_env('DB_TABLE_PREFIX', '')); + if ($prefix === '' && !app_is_dev()) { + throw new RuntimeException('DB_TABLE_PREFIX darf außerhalb der Entwicklungsumgebung nicht leer sein.'); + } + + return $prefix; +} + +function app_db_table_name(string $baseName, ?string $prefix = null): string +{ + if (!in_array($baseName, app_database_table_names(), true)) { + throw new InvalidArgumentException('Unbekannte Anwendungstabelle: ' . $baseName); + } + + return app_validate_db_table_prefix($prefix ?? app_db_table_prefix()) . $baseName; +} + +/** + * Schreibt ausschließlich bekannte Tabellen- und FK-Namen um. Das gilt auch + * innerhalb der dynamischen DDL-Strings in den Migrationen 0003/0008 und für + * deren INFORMATION_SCHEMA-Vergleiche. + */ +function app_prefix_database_sql(string $sql, ?string $prefix = null): string +{ + $prefix = app_validate_db_table_prefix($prefix ?? app_db_table_prefix()); + if ($prefix === '') { + return $sql; + } + + static $tablePattern = null; + if ($tablePattern === null) { + $tables = app_database_table_names(); + usort($tables, static fn(string $a, string $b): int => strlen($b) <=> strlen($a)); + $tablePattern = '/(? preg_quote($table, '/'), $tables)) + . ')(?![A-Za-z0-9_])/i'; + } + $sql = preg_replace_callback( + $tablePattern, + static fn(array $match): string => $prefix . $match[0], + $sql + ) ?? $sql; + + // MySQL verlangt innerhalb einer Datenbank eindeutige Namen für + // Fremdschlüssel-Constraints. Die Migrationen verwenden durchgängig + // sprechende fk_*-Namen; sie brauchen daher ebenfalls den Umgebungs-Prefix. + $sql = preg_replace_callback( + '/(? $prefix . $match[1], + $sql + ) ?? $sql; + + return $sql; +} + +final class AppPrefixedPDO extends PDO +{ + private string $tablePrefix = ''; + + public function setTablePrefix(string $prefix): void + { + $this->tablePrefix = app_validate_db_table_prefix($prefix); + } + + public function tablePrefix(): string + { + return $this->tablePrefix; + } + + public function prepare(string $query, array $options = []): PDOStatement|false + { + return parent::prepare(app_prefix_database_sql($query, $this->tablePrefix), $options); + } + + public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false + { + $query = app_prefix_database_sql($query, $this->tablePrefix); + if ($fetchMode === null) { + return parent::query($query); + } + + return parent::query($query, $fetchMode, ...$fetchModeArgs); + } + + public function exec(string $statement): int|false + { + return parent::exec(app_prefix_database_sql($statement, $this->tablePrefix)); + } +} diff --git a/docs/betrieb-backup-monitoring.md b/docs/betrieb-backup-monitoring.md index 1879c96..b62facd 100644 --- a/docs/betrieb-backup-monitoring.md +++ b/docs/betrieb-backup-monitoring.md @@ -36,6 +36,11 @@ Wichtige Punkte: - `--single-transaction` sorgt für einen konsistenten Snapshot ohne Tabellen zu sperren (InnoDB, wie hier durchgängig verwendet). +- Da `test_` und `prod_` in derselben physischen Datenbank liegen, enthält + der vollständige Dump beide logisch getrennten Schemas. Beim Restore darf + nicht nur ein einzelner Prefix zurückgespielt werden, ohne Abhängigkeiten + und den jeweiligen Stand von `test_schema_migrations` beziehungsweise + `prod_schema_migrations` zu prüfen. - Der Backup-Ordner muss **außerhalb** des über HTTP erreichbaren Webroots liegen, sonst wären Dumps öffentlich abrufbar. - Zusätzlich zu lokalen Backups auf dem Webspace mindestens eine Kopie an @@ -55,7 +60,9 @@ gunzip -c kaffeeliste_20260715_030000.sql.gz | mysql -h "$DB_HOST" -u "$DB_USER" Danach `php scripts/migrate.php` laufen lassen, falls das Backup älter als die zuletzt eingespielten Migrationen ist (das Skript ist idempotent und -wendet nur fehlende Migrationen an). +wendet nur fehlende Migrationen im konfigurierten `DB_TABLE_PREFIX` an). +Den Lauf daher mit der Konfiguration jeder wiederhergestellten Umgebung +separat ausführen. **Restore-Test:** Mindestens einmal pro Quartal einen echten Restore gegen eine separate Test-Datenbank durchführen und die App dagegen starten diff --git a/docs/deployment.md b/docs/deployment.md index 43089a9..c2234b8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,14 +9,22 @@ Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace ## Umgebungen -| Umgebung | Host | Webroot | Zweck | -| --- | --- | --- | --- | -| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | Vollständiger Durchlauf vor jedem Produktiv-Deploy | -| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | Echtbetrieb | +| Umgebung | Host | Webroot | Tabellen-Prefix | Zweck | +| --- | --- | --- | --- | --- | +| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | `test_` | Vollständiger Durchlauf vor jedem Produktiv-Deploy | +| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | `prod_` | Echtbetrieb | -Beide Umgebungen brauchen **eigene Datenbanken** und **eigene** -`env.local.php`. Die Staging-Umgebung läuft mit Stripe-Testkeys, die -Produktion mit Live-Keys. +Beide Umgebungen verwenden dieselbe physische Datenbank, aber zwingend +unterschiedliche Tabellen-Prefixe und jeweils eine eigene `env.local.php`. +Damit sind Daten, Migrationstabellen und Fremdschlüssel von Staging und +Produktion logisch getrennt. Die Staging-Umgebung läuft mit Stripe-Testkeys, +die Produktion mit Live-Keys. + +Der Prefix schützt vor versehentlichem Zugriff der einen Umgebung auf die +Tabellen der anderen, ersetzt aber keine getrennte Datenbank als harte +Sicherheitsgrenze: Ein kompromittierter gemeinsamer Datenbankzugang kann +weiterhin alle Tabellen sehen. Deshalb Zugangsdaten besonders restriktiv +behandeln und immer die gesamte Datenbank sichern. ### Host-Split beachten @@ -80,12 +88,13 @@ Die temporäre `phpinfo()`-Datei danach wieder löschen. ### 2. Datenbank anlegen -In Plesk eine leere MySQL-Datenbank samt eigenem Benutzer anlegen. Die -Zugangsdaten werden gleich in `env.local.php` eingetragen. +In Plesk eine MySQL-Datenbank samt Benutzer anlegen. Beide Umgebungen dürfen +dieselben Zugangsdaten verwenden; die Trennung erfolgt über +`DB_TABLE_PREFIX=test_` beziehungsweise `DB_TABLE_PREFIX=prod_`. -Die vorhandene Entwicklungsdatenbank **nicht** übernehmen: sie enthält -ausschließlich Testdaten inklusive des migrierten Default-Mandanten aus -der Legacy-App. +Unpräfixierte vorhandene Testdaten dürfen ausschließlich mit dem dafür +vorgesehenen Übernahmeskript nach `test_` kopiert werden. Sie dürfen niemals +nach `prod_` übernommen werden. ### 3. Dateien hochladen @@ -121,12 +130,16 @@ putenv('APP_TIMEZONE=Europe/Berlin'); putenv('APP_DB_DRIVER=mysql'); putenv('DB_HOST=localhost'); putenv('DB_NAME=…'); putenv('DB_USER=…'); putenv('DB_PASS=…'); +putenv('DB_TABLE_PREFIX=test_'); putenv('APP_SESSION_PATH=' . __DIR__ . '/var/sessions'); putenv('APP_MAIL_TRANSPORT=mail'); putenv('APP_MAIL_FROM=noreply@kaffeeliste.de'); ``` Stripe-, Dolibarr- und IMAP-Werte je Umgebung ergänzen (siehe unten). +In der produktiven `env.local.php` muss stattdessen +`DB_TABLE_PREFIX=prod_` stehen. Ein leerer Prefix ist im laufenden Betrieb +nicht zulässig. ### 5. Schreibrechte für `var/` @@ -139,7 +152,21 @@ den CSV-Import. Beide sind per `.htaccess` von außen gesperrt. Es gibt 30 versionierte Migrationen in `database/migrations/`, angewendet über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur -fehlende Migrationen an. +fehlende Migrationen an. Es arbeitet ausschließlich im Prefix aus der +jeweiligen `env.local.php`; daher müssen Migrationen einmal in Staging und +einmal in Produktion ausgeführt werden. + +Für die einmalige Übernahme vorhandener, bisher unpräfixierter Testdaten: + +```bash +php scripts/migrate-table-prefix.php --target-prefix=test_ --replace +``` + +Das Skript migriert zuerst das `test_`-Schema, ersetzt dessen Inhalt +transaktional durch eine exakte Kopie der unpräfixierten Daten und vergleicht +pro Tabelle die Zeilenzahl. Die unpräfixierten Quelltabellen bleiben als +Rückfallmöglichkeit unverändert. Vor dem Lauf trotzdem ein vollständiges +Datenbank-Backup erstellen. Ohne SSH-Zugang läuft das über **Plesk → Geplante Aufgaben (Scheduled Tasks)** als einmalig ausgeführter Task vom Typ *PHP-Skript ausführen*: @@ -348,7 +375,7 @@ Rechten der Secret-Dateien oder einer nicht angewendeten Legal-Migration. ein Vorlauf mit `--dry-run` wurde kontrolliert - [ ] Stündlicher Task `php scripts/purge-expired-operational-data.php` ist eingerichtet; ein Vorlauf mit `--dry-run` wurde kontrolliert -- [ ] Frische Produktivdatenbank ohne Testdaten +- [ ] Frisches `prod_`-Schema ohne Testdaten; Staging verwendet ausschließlich `test_` ## Bewusst offen diff --git a/docs/dev-mysql.md b/docs/dev-mysql.md index 610eeb3..3ff7410 100644 --- a/docs/dev-mysql.md +++ b/docs/dev-mysql.md @@ -44,6 +44,7 @@ export DB_PORT="3306" export DB_NAME="example_database" export DB_USER="example_user" export DB_PASS="example_password" +export DB_TABLE_PREFIX="test_" export DEV_AUTH_EMAIL="admin@test.local" export DEV_AUTH_NAME="Test Admin" # Optional: Standard ist var/sessions im Repository. @@ -95,7 +96,9 @@ der Port über die Ports-Ansicht weitergeleitet werden. Nachrichten unter `var/mail` ab. Für Produktion kann zunächst `APP_MAIL_TRANSPORT=mail` mit passendem `APP_BASE_URL` und Absender gesetzt werden; eine SMTP-/Provider-Anbindung bleibt ein späterer Betriebsausbau. -- Die Tabellen werden über `database/migrations/` angelegt. +- Die Tabellen werden über `database/migrations/` mit dem Prefix aus + `DB_TABLE_PREFIX` angelegt. Lokal und auf Staging ist das `test_`, in + Produktion ausschließlich `prod_`. - `database/mysql-dev-schema.sql` bleibt als historische Dev-Schema-Baseline erhalten; der aktive Weg ist `scripts/migrate.php`. - Session-Dateien liegen standardmäßig unter `var/sessions`; `var/` wird von diff --git a/docs/m2-technical-foundation.md b/docs/m2-technical-foundation.md index 5743aad..31ce121 100644 --- a/docs/m2-technical-foundation.md +++ b/docs/m2-technical-foundation.md @@ -111,8 +111,8 @@ LD_LIBRARY_PATH="$PWD/.local/php/usr/lib/x86_64-linux-gnu:$PWD/.local/php/usr/li ``` Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`, -`DB_USER` und `DB_PASS`. `scripts/init-mysql-dev.php` braucht zusätzlich -`DEV_AUTH_EMAIL`. +`DB_USER`, `DB_PASS` und `DB_TABLE_PREFIX`. `scripts/init-mysql-dev.php` +braucht zusätzlich `DEV_AUTH_EMAIL`. ## Aktueller Prüfstatus diff --git a/env.local.example.php b/env.local.example.php index b3aab9b..b0677c5 100644 --- a/env.local.example.php +++ b/env.local.example.php @@ -36,6 +36,9 @@ putenv('DB_PORT=3306'); putenv('DB_NAME='); putenv('DB_USER='); putenv('DB_PASS='); +// Bei gemeinsamer physischer Datenbank zwingend pro Umgebung verschieden: +// Produktion `prod_`, Test/Staging `test_`. Der Prefix endet immer auf `_`. +putenv('DB_TABLE_PREFIX=prod_'); // Muss ausserhalb des oeffentlichen Webroots oder zumindest per .htaccess // gesperrt sein - siehe Deployment-Hinweise. diff --git a/scripts/check-db-table-prefix.php b/scripts/check-db-table-prefix.php new file mode 100644 index 0000000..7257484 --- /dev/null +++ b/scripts/check-db-table-prefix.php @@ -0,0 +1,96 @@ +getMessage(); +} + $phone = app_legal_phone(); if (strlen(preg_replace('/\D/', '', $phone) ?? '') < 7) { $errors[] = 'LEGAL_PHONE fehlt oder ist unplausibel; B2C darf so nicht freigeschaltet werden.'; @@ -66,10 +76,13 @@ if (preg_match('~@import\s+url\(["\']?https?://|url\(["\']?https?://~i', $mainCs try { $pdo = app_db_pdo(); foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) { - $stmt = $pdo->prepare('SHOW TABLES LIKE ?'); - $stmt->execute([$table]); + $stmt = $pdo->prepare( + 'SELECT 1 FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?' + ); + $stmt->execute([app_db_table_name($table)]); if ($stmt->fetchColumn() === false) { - $errors[] = "Datenbanktabelle {$table} fehlt; Migrationen ausführen."; + $errors[] = "Datenbanktabelle " . app_db_table_name($table) . ' fehlt; Migrationen ausführen.'; } } $stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?'); diff --git a/scripts/dev-db.php b/scripts/dev-db.php index 09d6a64..1783dfd 100644 --- a/scripts/dev-db.php +++ b/scripts/dev-db.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../app/bootstrap.php'; +require_once __DIR__ . '/../app/prefixed-pdo.php'; function dev_required_env(string $name): string { @@ -15,7 +16,7 @@ function dev_required_env(string $name): string return $value; } -function dev_pdo(): PDO +function dev_pdo_for_prefix(string $prefix): AppPrefixedPDO { $dsn = sprintf( 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', @@ -24,11 +25,19 @@ function dev_pdo(): PDO dev_required_env('DB_NAME') ); - return new PDO($dsn, dev_required_env('DB_USER'), dev_required_env('DB_PASS'), [ + $pdo = new AppPrefixedPDO($dsn, dev_required_env('DB_USER'), dev_required_env('DB_PASS'), [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => true, ]); + $pdo->setTablePrefix($prefix); + + return $pdo; +} + +function dev_pdo(): AppPrefixedPDO +{ + return dev_pdo_for_prefix(app_db_table_prefix()); } function dev_apply_schema(PDO $pdo): void diff --git a/scripts/migrate-table-prefix.php b/scripts/migrate-table-prefix.php new file mode 100644 index 0000000..03bbac8 --- /dev/null +++ b/scripts/migrate-table-prefix.php @@ -0,0 +1,150 @@ +prepare( + 'SELECT 1 FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?' + ); + $stmt->execute([$table]); + + return $stmt->fetchColumn() !== false; +}; + +/** @return list */ +$tableColumns = static function (PDO $pdo, string $table): array { + $stmt = $pdo->prepare( + 'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION' + ); + $stmt->execute([$table]); + + return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)); +}; + +$targetsToClear = []; +$copies = []; +$sourceTableCount = 0; + +foreach (app_database_table_names() as $baseName) { + if ($baseName === 'schema_migrations') { + continue; + } + + $sourceTable = app_db_table_name($baseName, $sourcePrefix); + $targetTable = app_db_table_name($baseName, $targetPrefix); + $sourceExists = $tableExists($rawPdo, $sourceTable); + $targetExists = $tableExists($rawPdo, $targetTable); + + if ($sourceExists) { + $sourceTableCount++; + } + if ($sourceExists && !$targetExists) { + throw new RuntimeException("Zieltabelle {$targetTable} fehlt nach den Migrationen."); + } + if (!$targetExists) { + continue; + } + + $targetsToClear[] = $targetTable; + if (!$sourceExists) { + continue; + } + + $sourceColumns = $tableColumns($rawPdo, $sourceTable); + $targetColumns = $tableColumns($rawPdo, $targetTable); + if ($sourceColumns !== $targetColumns) { + throw new RuntimeException("Spalten von {$sourceTable} und {$targetTable} stimmen nicht überein."); + } + $copies[] = [$sourceTable, $targetTable, $sourceColumns]; +} + +if ($sourceTableCount === 0) { + throw new RuntimeException('Im Quellschema wurden keine Anwendungstabellen gefunden.'); +} + +$copiedRows = 0; +$rawPdo->exec('SET FOREIGN_KEY_CHECKS = 0'); +try { + $rawPdo->beginTransaction(); + + foreach ($targetsToClear as $targetTable) { + $rawPdo->exec('DELETE FROM ' . $quoteIdentifier($targetTable)); + } + + foreach ($copies as [$sourceTable, $targetTable, $columns]) { + $quotedColumns = implode(', ', array_map($quoteIdentifier, $columns)); + $sourceRows = (int)$rawPdo + ->query('SELECT COUNT(*) FROM ' . $quoteIdentifier($sourceTable)) + ->fetchColumn(); + + if ($sourceRows > 0) { + $rawPdo->exec( + 'INSERT INTO ' . $quoteIdentifier($targetTable) + . ' (' . $quotedColumns . ') SELECT ' . $quotedColumns + . ' FROM ' . $quoteIdentifier($sourceTable) + ); + } + + $targetRows = (int)$rawPdo + ->query('SELECT COUNT(*) FROM ' . $quoteIdentifier($targetTable)) + ->fetchColumn(); + if ($sourceRows !== $targetRows) { + throw new RuntimeException( + "Zeilenzahl stimmt für {$sourceTable} -> {$targetTable} nicht: {$sourceRows} != {$targetRows}." + ); + } + $copiedRows += $targetRows; + echo "{$sourceTable} -> {$targetTable}: {$targetRows} Zeile(n)\n"; + } + + $rawPdo->commit(); +} catch (Throwable $e) { + if ($rawPdo->inTransaction()) { + $rawPdo->rollBack(); + } + throw $e; +} finally { + $rawPdo->exec('SET FOREIGN_KEY_CHECKS = 1'); +} + +echo "Datenübernahme abgeschlossen: {$copiedRows} Zeile(n). Das Quellschema blieb unverändert.\n";