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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 22:18:48 +02:00
co-authored by Claude Sonnet 5
parent 8b0dcd2c70
commit cbe6547f3a
8 changed files with 494 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/saas-auth.php';
require_once __DIR__ . '/audit.php';
/**
* Platform-admin access is deliberately a separate concept from
* tenant_memberships/saas_user_has_role(): it must never be wired into the
* regular tenant-scoped pages, or the tenant isolation those pages and
* scripts/check-m8-tenant-isolation.php rely on would silently break. It
* only gates the backoffice-*.php pages.
*/
function app_is_platform_admin(PDO $pdo, ?int $userId): bool
{
if ($userId === null) {
return false;
}
$stmt = $pdo->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<array{id: int, slug: string, name: string, status: string, created_at: string, participant_count: int, active_participant_count: int, balance_cents: int}>
*/
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<array>, recent_entries: list<array>, recent_audit: list<array>}|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,
];
}
+40
View File
@@ -0,0 +1,40 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php';
require_once __DIR__ . '/app/data-export.php';
$pdo = app_db_pdo();
$user = app_require_platform_admin($pdo);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Nur POST erlaubt.');
}
app_require_csrf();
$tenantId = filter_input(INPUT_POST, 'tenant_id', FILTER_VALIDATE_INT);
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
http_response_code(400);
exit('Ungültige Mandanten-ID.');
}
$stmt = $pdo->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;
+104
View File
@@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php';
$pdo = app_db_pdo();
$user = app_require_platform_admin($pdo);
$tenantId = filter_input(INPUT_GET, 'tenant_id', FILTER_VALIDATE_INT);
if ($tenantId === null || $tenantId === false || $tenantId <= 0) {
http_response_code(400);
exit('Ungültige Mandanten-ID.');
}
$detail = app_backoffice_fetch_tenant_detail($pdo, $tenantId);
if ($detail === null) {
http_response_code(404);
exit('Mandant wurde nicht gefunden.');
}
// Jede Ansicht eines Mandanten durch das Back-Office wird im Audit-Log
// dieses Mandanten protokolliert, damit der Zugriff fuer den Kunden
// nachvollziehbar bleibt (siehe AVV-Transparenzpflicht).
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'backoffice.tenant_viewed', 'tenant', $tenantId);
$rollenLabels = ['owner' => 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter'];
include 'header.php';
include 'headerline.php';
include 'nav.php';
?>
<section id="banner">
<div class="content">
<h2>Back-Office: <?php echo saas_html($detail['tenant']['name']); ?></h2>
<p><a href="backoffice.php">&larr; Zurück zur Mandantenübersicht</a></p>
<table>
<tr><th>Kürzel</th><td><?php echo saas_html($detail['tenant']['slug']); ?></td></tr>
<tr><th>Status</th><td><?php echo saas_html($detail['tenant']['status']); ?></td></tr>
<tr><th>Erstellt</th><td><?php echo saas_html($detail['tenant']['created_at']); ?></td></tr>
<?php if ($detail['settings'] !== null): ?>
<tr><th>Preis pro Strich</th><td><?php echo saas_html(saas_format_money_cents((int)$detail['settings']['mark_price_cents'])); ?> €</td></tr>
<tr><th>PayPal aktiv</th><td><?php echo (int)$detail['settings']['paypal_enabled'] === 1 ? 'ja' : 'nein'; ?></td></tr>
<?php endif; ?>
</table>
<form method="post" action="backoffice-export.php">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="tenant_id" value="<?php echo (int)$tenantId; ?>">
<button type="submit">Datenexport dieses Mandanten herunterladen</button>
</form>
<h3>Mitglieder mit Zugang</h3>
<table>
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr>
<?php if ($detail['members'] === []): ?>
<tr><td colspan="4">Kein Mitglied mit Login-Zugang.</td></tr>
<?php endif; ?>
<?php foreach ($detail['members'] as $member): ?>
<tr>
<td><?php echo saas_html($member['display_name']); ?></td>
<td><?php echo saas_html($member['email']); ?></td>
<td><?php echo saas_html($rollenLabels[$member['role']] ?? $member['role']); ?></td>
<td><?php echo saas_html($member['membership_status']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<h3>Letzte Buchungen</h3>
<table>
<tr><th>Datum</th><th>Mitglied</th><th>Typ</th><th>Betrag</th><th>Quelle</th></tr>
<?php if ($detail['recent_entries'] === []): ?>
<tr><td colspan="5">Keine Buchungen vorhanden.</td></tr>
<?php endif; ?>
<?php foreach ($detail['recent_entries'] as $entry): ?>
<tr>
<td><?php echo saas_html($entry['booked_at']); ?></td>
<td><?php echo saas_html($entry['display_name']); ?></td>
<td><?php echo saas_html($entry['type']); ?></td>
<td><?php echo saas_html(saas_format_money_cents((int)$entry['amount_cents'])); ?> €</td>
<td><?php echo saas_html($entry['source']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<h3>Letzte Admin-Aktionen</h3>
<table>
<tr><th>Datum</th><th>Wer</th><th>Aktion</th></tr>
<?php if ($detail['recent_audit'] === []): ?>
<tr><td colspan="3">Noch keine protokollierten Aktionen.</td></tr>
<?php endif; ?>
<?php foreach ($detail['recent_audit'] as $entry): ?>
<tr>
<td><?php echo saas_html($entry['created_at']); ?></td>
<td><?php echo saas_html($entry['actor_name'] ?? '—'); ?></td>
<td><?php echo saas_html($entry['action']); ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
</section>
<?php include 'footer.php'; ?>
+47
View File
@@ -0,0 +1,47 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/platform-admin.php';
$pdo = app_db_pdo();
$user = app_require_platform_admin($pdo);
$tenants = app_backoffice_fetch_tenants($pdo);
include 'header.php';
include 'headerline.php';
include 'nav.php';
?>
<section id="banner">
<div class="content">
<h2>Back-Office</h2>
<p>Nur für Betreiber-Admins sichtbar: Übersicht aller Mandanten, unabhängig von deren eigener Mitgliedschaft.
Jeder Zugriff wird im Audit-Log des jeweiligen Mandanten protokolliert.</p>
<table>
<tr>
<th>Kunde</th>
<th>Kürzel</th>
<th>Status</th>
<th>Erstellt</th>
<th>Teilnehmer (aktiv/gesamt)</th>
<th>Saldensumme</th>
<th></th>
</tr>
<?php foreach ($tenants as $tenant): ?>
<tr>
<td><?php echo saas_html($tenant['name']); ?></td>
<td><?php echo saas_html($tenant['slug']); ?></td>
<td><?php echo saas_html($tenant['status']); ?></td>
<td><?php echo saas_html($tenant['created_at']); ?></td>
<td><?php echo (int)$tenant['active_participant_count']; ?> / <?php echo (int)$tenant['participant_count']; ?></td>
<td><?php echo saas_html(saas_format_money_cents((int)$tenant['balance_cents'])); ?> €</td>
<td><a href="backoffice-mandant.php?tenant_id=<?php echo (int)$tenant['id']; ?>" class="button">Ansehen</a></td>
</tr>
<?php endforeach; ?>
</table>
</div>
</section>
<?php include 'footer.php'; ?>
@@ -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;
+110
View File
@@ -0,0 +1,110 @@
# Back-Office (Platform-Admin)
Stand: 2026-07-16
Ergänzung außerhalb der ursprünglichen M0M9-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.
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
/**
* CLI-only bootstrap: grants (or confirms) platform-admin/backoffice access
* for an existing user by email. There is no web UI to grant the first
* platform admin on purpose, so this can only be done with direct DB/shell
* access, not through any public endpoint.
*
* Usage: php scripts/grant-platform-admin.php someone@example.com
*/
$email = trim((string)($argv[1] ?? ''));
if ($email === '') {
fwrite(STDERR, "Usage: php scripts/grant-platform-admin.php <email>\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";
+10
View File
@@ -122,6 +122,16 @@ $checks = [
'path' => 'mandant-loeschen.php', 'path' => 'mandant-loeschen.php',
'contains' => ['Login'], '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', 'label' => 'Teilnehmerauswertung',
'path' => 'teilnehmerauswertung.php?user_id=1', 'path' => 'teilnehmerauswertung.php?user_id=1',