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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user