From cbe6547f3a6f0608845837d6b0ce30f7876ac13d Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Thu, 16 Jul 2026 22:18:48 +0200 Subject: [PATCH] Back-Office: globaler Platform-Admin-Zugang ueber alle Mandanten Neue, bewusst von tenant_memberships/saas_user_has_role() komplett getrennte Platform-Admin-Ebene (neue Tabelle platform_admins), damit die bestehende, automatisiert getestete Mandanten-Isolation (check-m8-tenant-isolation.php, check-m8-role-matrix.php) unangetastet bleibt - Platform-Admin-Rechte wirken ausschliesslich auf den neuen backoffice-*.php-Seiten. - backoffice.php: Uebersicht aller Mandanten (Status, Teilnehmerzahl, Saldensumme). - backoffice-mandant.php: reine Leseansicht eines Mandanten (Einstellungen, Mitglieder/Rollen, letzte Buchungen, letzte Admin-Aktionen). Bewusst kein Schreibzugriff von hier aus. - backoffice-export.php: nutzt dieselbe app_export_tenant_data() wie der Selbstbedienungs-Export, ausgeloest durch den Platform-Admin fuer beliebige Mandanten. - scripts/grant-platform-admin.php: CLI-only Bootstrap fuer den ersten Platform-Admin, bewusst keine Web-UI dafuer. - Jede Back-Office-Ansicht/-Export wird im Audit-Log DES BETROFFENEN MANDANTEN protokolliert (Transparenzpflicht), nicht nur beim Betreiber. Der bestehende Selbstbedienungs-Export (datenexport.php aus M8) bleibt zusaetzlich bestehen statt ersetzt zu werden: der Mandant ist im AV- Verhaeltnis Verantwortlicher, Art. 15/20/28 DSGVO verpflichten den Auftragsverarbeiter zur Unterstuetzung bei Ausk''unfts-/Portabilitaets- rechten - ein jederzeit verfuegbarer Mandanten-Export erfuellt das direkt. Details und Begruendung in docs/backoffice.md. Live getestet: Back-Office zeigt alle Mandanten korrekt (inkl. echter Bestandsmandanten), Detail/Export fuer Test-Mandant funktioniert, Audit-Log korrekt geschrieben. Kritischer Test bestanden: derselbe Platform-Admin sieht auf normalen Mandanten-Seiten weiterhin nur seinen eigenen Mandanten; ein eingeloggter Nicht-Platform-Admin bekommt 403. Co-Authored-By: Claude Sonnet 5 --- app/platform-admin.php | 129 ++++++++++++++++++ backoffice-export.php | 40 ++++++ backoffice-mandant.php | 104 ++++++++++++++ backoffice.php | 47 +++++++ .../migrations/0013_saas_platform_admins.sql | 11 ++ docs/backoffice.md | 110 +++++++++++++++ scripts/grant-platform-admin.php | 43 ++++++ scripts/http-smoke.php | 10 ++ 8 files changed, 494 insertions(+) create mode 100644 app/platform-admin.php create mode 100644 backoffice-export.php create mode 100644 backoffice-mandant.php create mode 100644 backoffice.php create mode 100644 database/migrations/0013_saas_platform_admins.sql create mode 100644 docs/backoffice.md create mode 100644 scripts/grant-platform-admin.php diff --git a/app/platform-admin.php b/app/platform-admin.php new file mode 100644 index 0000000..47287cc --- /dev/null +++ b/app/platform-admin.php @@ -0,0 +1,129 @@ +prepare('SELECT 1 FROM platform_admins WHERE user_id = ?'); + $stmt->execute([$userId]); + + return $stmt->fetchColumn() !== false; +} + +/** + * Requires a real SaaS login (any tenant role, or none) plus a + * platform_admins entry. Exits with 403 otherwise. + * + * @return array{user_id: int, email: string, display_name: string} + */ +function app_require_platform_admin(PDO $pdo): array +{ + $user = saas_current_user($pdo); + if ($user === null) { + header('Location: login.php'); + exit; + } + + if (!app_is_platform_admin($pdo, (int)$user['user_id'])) { + http_response_code(403); + exit('Kein Zugriff.'); + } + + return $user; +} + +/** + * @return list + */ +function app_backoffice_fetch_tenants(PDO $pdo): array +{ + $stmt = $pdo->query( + "SELECT + t.id, t.slug, t.name, t.status, t.created_at, + COUNT(p.id) AS participant_count, + COALESCE(SUM(p.active), 0) AS active_participant_count, + COALESCE(( + SELECT SUM(le.amount_cents) + FROM ledger_entries le + WHERE le.tenant_id = t.id AND le.voided_at IS NULL + ), 0) AS balance_cents + FROM tenants t + LEFT JOIN participants p ON p.tenant_id = t.id + GROUP BY t.id, t.slug, t.name, t.status, t.created_at + ORDER BY t.created_at DESC" + ); + + return $stmt->fetchAll(); +} + +/** + * @return array{tenant: array, settings: ?array, members: list, recent_entries: list, recent_audit: list}|null + */ +function app_backoffice_fetch_tenant_detail(PDO $pdo, int $tenantId): ?array +{ + $stmt = $pdo->prepare('SELECT id, slug, name, status, timezone, locale, currency_code, created_at FROM tenants WHERE id = ?'); + $stmt->execute([$tenantId]); + $tenant = $stmt->fetch(); + if ($tenant === false) { + return null; + } + + $stmt = $pdo->prepare('SELECT * FROM tenant_settings WHERE tenant_id = ?'); + $stmt->execute([$tenantId]); + $settings = $stmt->fetch() ?: null; + + $stmt = $pdo->prepare( + 'SELECT u.id, u.email, u.display_name, u.status, tm.role, tm.status AS membership_status + FROM tenant_memberships tm + JOIN users u ON u.id = tm.user_id + WHERE tm.tenant_id = ? + ORDER BY tm.role, u.display_name' + ); + $stmt->execute([$tenantId]); + $members = $stmt->fetchAll(); + + $stmt = $pdo->prepare( + 'SELECT le.id, le.type, le.amount_cents, le.booked_at, le.source, p.display_name + FROM ledger_entries le + JOIN participants p ON p.id = le.participant_id + WHERE le.tenant_id = ? AND le.voided_at IS NULL + ORDER BY le.booked_at DESC, le.id DESC + LIMIT 20' + ); + $stmt->execute([$tenantId]); + $recentEntries = $stmt->fetchAll(); + + $stmt = $pdo->prepare( + 'SELECT a.id, a.action, a.subject_type, a.subject_id, a.created_at, u.display_name AS actor_name + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_user_id + WHERE a.tenant_id = ? + ORDER BY a.created_at DESC, a.id DESC + LIMIT 20' + ); + $stmt->execute([$tenantId]); + $recentAudit = $stmt->fetchAll(); + + return [ + 'tenant' => $tenant, + 'settings' => $settings, + 'members' => $members, + 'recent_entries' => $recentEntries, + 'recent_audit' => $recentAudit, + ]; +} diff --git a/backoffice-export.php b/backoffice-export.php new file mode 100644 index 0000000..3821588 --- /dev/null +++ b/backoffice-export.php @@ -0,0 +1,40 @@ +prepare('SELECT slug FROM tenants WHERE id = ?'); +$stmt->execute([$tenantId]); +$slug = $stmt->fetchColumn(); +if ($slug === false) { + http_response_code(404); + exit('Mandant wurde nicht gefunden.'); +} + +$data = app_export_tenant_data($pdo, $tenantId); +app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_exported', 'tenant', $tenantId); + +$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +$filename = 'backoffice-export-' . $slug . '-' . date('Ymd_His') . '.json'; + +header('Content-Type: application/json; charset=UTF-8'); +header('Content-Disposition: attachment; filename="' . $filename . '"'); +header('Content-Length: ' . strlen((string)$json)); +echo $json; diff --git a/backoffice-mandant.php b/backoffice-mandant.php new file mode 100644 index 0000000..987d19d --- /dev/null +++ b/backoffice-mandant.php @@ -0,0 +1,104 @@ + 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter']; + +include 'header.php'; +include 'headerline.php'; +include 'nav.php'; +?> + + + + diff --git a/backoffice.php b/backoffice.php new file mode 100644 index 0000000..2dc367b --- /dev/null +++ b/backoffice.php @@ -0,0 +1,47 @@ + + + + + diff --git a/database/migrations/0013_saas_platform_admins.sql b/database/migrations/0013_saas_platform_admins.sql new file mode 100644 index 0000000..c61b750 --- /dev/null +++ b/database/migrations/0013_saas_platform_admins.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS platform_admins ( + user_id INT PRIMARY KEY, + granted_by_user_id INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_platform_admins_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT fk_platform_admins_granted_by + FOREIGN KEY (granted_by_user_id) REFERENCES users(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/backoffice.md b/docs/backoffice.md new file mode 100644 index 0000000..af84bfd --- /dev/null +++ b/docs/backoffice.md @@ -0,0 +1,110 @@ +# Back-Office (Platform-Admin) + +Stand: 2026-07-16 + +Ergänzung außerhalb der ursprünglichen M0–M9-Meilensteine: ein +Betreiber-Zugang, der mandantenübergreifend alle Kunden einsehen kann – +getrennt von der normalen, streng mandantengebundenen Rollenlogik. + +## Design-Entscheidung: getrennt von `tenant_memberships` + +Platform-Admin-Rechte sind bewusst **kein** Teil von +`saas_user_has_role()`/`tenant_memberships`, sondern eine eigene, komplett +separate Tabelle `platform_admins` (`user_id` → `users.id`). Das ist +kein Zufall: Alle bestehenden Mandanten-Seiten und die automatisierten +Isolationstests (`scripts/check-m8-tenant-isolation.php`, +`scripts/check-m8-role-matrix.php`) verlassen sich darauf, dass ein +Login niemals automatisch mandantenübergreifenden Zugriff bekommt. Ein +Platform-Admin-Flag direkt in `tenant_memberships` oder `users` einzubauen +hätte dieses Fundament unterlaufen können. Stattdessen prüft +ausschließlich der neue Code-Pfad in `app/platform-admin.php` +(`app_require_platform_admin()`) auf Back-Office-Seiten die +Platform-Admin-Eigenschaft; alle bestehenden Seiten sind unverändert und +bleiben strikt mandantengebunden. + +## Umgesetzte Dateien + +```text +database/migrations/0013_saas_platform_admins.sql +app/platform-admin.php +scripts/grant-platform-admin.php +backoffice.php +backoffice-mandant.php +backoffice-export.php +``` + +## Erster Platform-Admin + +Es gibt bewusst **keine Weboberfläche**, um den ersten Platform-Admin zu +setzen – das wäre ein öffentlich erreichbarer "werde Admin"-Endpunkt und +ein erhebliches Risiko. Stattdessen: Person registriert sich normal als +Mandant (oder nutzt einen bestehenden Login), danach per Shell-Zugriff auf +dem Server: + +```bash +php scripts/grant-platform-admin.php person@example.com +``` + +Das Skript prüft, dass der Account existiert, und legt nur dann den +`platform_admins`-Eintrag an. Zugang entziehen aktuell nur per direktem +`DELETE FROM platform_admins WHERE user_id = ?` (keine UI dafür – aus +demselben Grund wie beim Setzen). + +## Funktionsumfang + +- `backoffice.php`: Übersicht aller Mandanten mit Kürzel, Status, + Erstelldatum, Teilnehmerzahl (aktiv/gesamt) und Saldensumme. +- `backoffice-mandant.php?tenant_id=X`: reine Leseansicht eines einzelnen + Mandanten – Einstellungen, Mitglieder mit Rolle, letzte 20 Buchungen, + letzte 20 Admin-Aktionen. Absichtlich **kein** Schreibzugriff auf + Mandantendaten von hier aus, um das Risiko einer versehentlichen + Fremdänderung auszuschließen. +- `backoffice-export.php`: nutzt dieselbe `app_export_tenant_data()`- + Funktion wie der Selbstbedienungs-Export, aber ausgelöst durch den + Platform-Admin für einen beliebigen Mandanten. + +## Verhältnis zum Selbstbedienungs-Export (`datenexport.php`) + +Bewusste Entscheidung: Der bestehende Selbstbedienungs-Export für +Mandanten-Owner/Admin (`datenexport.php`, aus M8) bleibt zusätzlich +bestehen, statt ihn durch das Back-Office zu ersetzen. Begründung: Der +Mandant ist im Auftragsverarbeitungs-Verhältnis der Verantwortliche +(Controller), der Betreiber dieser App der Auftragsverarbeiter +(Processor). Art. 15/20 DSGVO geben den betroffenen Personen ein +Auskunfts-/Portabilitätsrecht, und Art. 28 DSGVO verpflichtet den +Auftragsverarbeiter, den Verantwortlichen bei der Erfüllung dieser +Rechte zu unterstützen sowie Daten am Vertragsende zurückzugeben – ein +jederzeit verfügbarer Selbstbedienungs-Export erfüllt genau das. Das +Back-Office ergänzt das um einen Betreiber-seitigen Zugriff für Support, +Migrationen oder eigene Nachweispflichten, ersetzt den +Mandanten-Selbstexport aber nicht. + +## Audit-Trail + +Jede Back-Office-Ansicht und jeder Back-Office-Export wird im Audit-Log +**des betroffenen Mandanten** protokolliert +(`backoffice.tenant_viewed`, `backoffice.tenant_exported`, mit dem +Platform-Admin als `actor_user_id`). Ein Mandant sieht damit über sein +eigenes Protokoll (`mandant-einstellungen.php`), wann der Betreiber auf +seine Daten zugegriffen hat – Transparenzpflicht statt stiller +Einsicht. + +## Prüfstatus + +- `scripts/check-m8-tenant-isolation.php`: weiterhin grün mit 10 + Assertions – das Back-Office berührt die geprüften Pfade nicht. +- `scripts/check-m8-role-matrix.php`: weiterhin grün mit 55 Assertions. +- HTTP-Smoke: grün mit 30 geprüften Seiten (zwei neue Login-Schutz-Checks + für `backoffice.php`/`backoffice-mandant.php`). +- Golden Master: grün mit 104 Assertions. + +Live getestet: eigens angelegter Test-Mandant, per CLI-Skript zum +Platform-Admin gemacht, Back-Office-Übersicht zeigt korrekt alle +Mandanten (inklusive der echten Bestandsmandanten), Detailansicht und +Export für den eigenen Test-Mandanten funktionieren, Audit-Log-Einträge +korrekt geschrieben. Kritischer Isolationstest bestätigt: derselbe +Platform-Admin sieht auf regulären Mandanten-Seiten (`kaffeeliste.php`) +weiterhin ausschließlich seinen eigenen Mandanten. Ein zweiter, +eingeloggter, aber nicht privilegierter Testnutzer bekommt auf +`backoffice.php` korrekt `403 Forbidden`. Alle Testdaten anschließend +vollständig entfernt. diff --git a/scripts/grant-platform-admin.php b/scripts/grant-platform-admin.php new file mode 100644 index 0000000..914adfa --- /dev/null +++ b/scripts/grant-platform-admin.php @@ -0,0 +1,43 @@ +\n"); + exit(1); +} + +$emailNorm = strtolower($email); +$pdo = dev_pdo(); + +$stmt = $pdo->prepare('SELECT id, display_name FROM users WHERE email_norm = ?'); +$stmt->execute([$emailNorm]); +$user = $stmt->fetch(); + +if ($user === false) { + fwrite(STDERR, "Kein Nutzerkonto mit dieser E-Mail gefunden. Zuerst registrieren/einloggen, dann erneut ausführen.\n"); + exit(1); +} + +$stmt = $pdo->prepare('SELECT 1 FROM platform_admins WHERE user_id = ?'); +$stmt->execute([(int)$user['id']]); +if ($stmt->fetchColumn() !== false) { + echo "{$email} ist bereits Platform-Admin.\n"; + exit(0); +} + +$pdo->prepare('INSERT INTO platform_admins (user_id) VALUES (?)')->execute([(int)$user['id']]); + +echo "{$email} ({$user['display_name']}) ist jetzt Platform-Admin und kann das Back-Office unter backoffice.php nutzen.\n"; diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php index 8511e46..39042d7 100644 --- a/scripts/http-smoke.php +++ b/scripts/http-smoke.php @@ -122,6 +122,16 @@ $checks = [ 'path' => 'mandant-loeschen.php', 'contains' => ['Login'], ], + [ + 'label' => 'Back-Office Login-Schutz', + 'path' => 'backoffice.php', + 'contains' => ['Login'], + ], + [ + 'label' => 'Back-Office Mandantendetail Login-Schutz', + 'path' => 'backoffice-mandant.php?tenant_id=1', + 'contains' => ['Login'], + ], [ 'label' => 'Teilnehmerauswertung', 'path' => 'teilnehmerauswertung.php?user_id=1',