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:
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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">← 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'; ?>
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user