M3 Tenant-Aufloesung per App-Session ergaenzen
- Mandantenauswahl fuer Benutzer mit mehreren Tenants bauen - feste Tenant-Domains ohne Wildcard-Abhaengigkeit vorbereiten - Login, Backfill, Smoke-Checks und M3-Dokumentation aktualisieren
This commit is contained in:
+196
-20
@@ -34,6 +34,70 @@ function saas_token_hash(string $token): string
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
function saas_normalize_host(?string $host): string
|
||||
{
|
||||
$host = strtolower(trim((string)$host));
|
||||
if ($host === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_contains($host, '://')) {
|
||||
$parsedHost = parse_url($host, PHP_URL_HOST);
|
||||
$host = is_string($parsedHost) ? $parsedHost : $host;
|
||||
}
|
||||
|
||||
$host = preg_replace('/:\d+$/', '', $host) ?? $host;
|
||||
|
||||
return trim($host, " \t\n\r\0\x0B.");
|
||||
}
|
||||
|
||||
function saas_primary_app_host(): string
|
||||
{
|
||||
return saas_normalize_host(app_env('APP_PRIMARY_HOST', ''));
|
||||
}
|
||||
|
||||
function saas_is_primary_app_host(?string $host = null): bool
|
||||
{
|
||||
$primaryHost = saas_primary_app_host();
|
||||
if ($primaryHost === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = $host ?? ($_SERVER['HTTP_HOST'] ?? '');
|
||||
|
||||
return saas_normalize_host($host) === $primaryHost;
|
||||
}
|
||||
|
||||
function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array
|
||||
{
|
||||
$host = saas_normalize_host($host ?? ($_SERVER['HTTP_HOST'] ?? ''));
|
||||
if ($host === '' || saas_is_primary_app_host($host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT
|
||||
td.id AS tenant_domain_id,
|
||||
td.domain,
|
||||
td.domain_norm,
|
||||
td.domain_type,
|
||||
t.id AS tenant_id,
|
||||
t.slug AS tenant_slug,
|
||||
t.name AS tenant_name,
|
||||
t.status AS tenant_status
|
||||
FROM tenant_domains td
|
||||
JOIN tenants t ON t.id = td.tenant_id
|
||||
WHERE td.domain_norm = ?
|
||||
AND td.status = ?
|
||||
AND t.status = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$host, 'active', 'active']);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
|
||||
function saas_parse_money_cents(mixed $value): ?int
|
||||
{
|
||||
$normalized = trim((string)$value);
|
||||
@@ -137,12 +201,19 @@ function saas_session_login(array $identity): void
|
||||
$_SESSION['saas_user_id'] = (int)$identity['user_id'];
|
||||
$_SESSION['saas_tenant_id'] = (int)$identity['tenant_id'];
|
||||
$_SESSION['saas_role'] = (string)$identity['role'];
|
||||
saas_clear_pending_tenant_selection();
|
||||
}
|
||||
|
||||
function saas_logout(): void
|
||||
{
|
||||
app_start_session();
|
||||
unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']);
|
||||
unset(
|
||||
$_SESSION['saas_user_id'],
|
||||
$_SESSION['saas_tenant_id'],
|
||||
$_SESSION['saas_role'],
|
||||
$_SESSION['saas_pending_user_id'],
|
||||
$_SESSION['saas_pending_started_at']
|
||||
);
|
||||
}
|
||||
|
||||
function saas_require_login(): array
|
||||
@@ -380,6 +451,107 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = '
|
||||
return $identity !== false ? $identity : null;
|
||||
}
|
||||
|
||||
function saas_list_user_memberships(PDO $pdo, int $userId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT
|
||||
u.id AS user_id,
|
||||
u.email,
|
||||
u.email_norm,
|
||||
u.display_name,
|
||||
u.status AS user_status,
|
||||
u.email_verified_at,
|
||||
t.id AS tenant_id,
|
||||
t.slug AS tenant_slug,
|
||||
t.name AS tenant_name,
|
||||
t.status AS tenant_status,
|
||||
tm.role,
|
||||
tm.status AS membership_status
|
||||
FROM users u
|
||||
JOIN tenant_memberships tm ON tm.user_id = u.id
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE u.id = ?
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
ORDER BY
|
||||
CASE tm.role
|
||||
WHEN \'owner\' THEN 1
|
||||
WHEN \'admin\' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
t.name'
|
||||
);
|
||||
$stmt->execute([$userId, 'active', 'active', 'active']);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT
|
||||
u.id AS user_id,
|
||||
u.email,
|
||||
u.email_norm,
|
||||
u.display_name,
|
||||
u.status AS user_status,
|
||||
u.email_verified_at,
|
||||
t.id AS tenant_id,
|
||||
t.slug AS tenant_slug,
|
||||
t.name AS tenant_name,
|
||||
t.status AS tenant_status,
|
||||
tm.role,
|
||||
tm.status AS membership_status
|
||||
FROM users u
|
||||
JOIN tenant_memberships tm ON tm.user_id = u.id
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE u.id = ?
|
||||
AND t.id = ?
|
||||
AND u.status = ?
|
||||
AND tm.status = ?
|
||||
AND t.status = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
||||
$identity = $stmt->fetch();
|
||||
|
||||
return $identity !== false ? $identity : null;
|
||||
}
|
||||
|
||||
function saas_start_pending_tenant_selection(int $userId): void
|
||||
{
|
||||
app_start_session();
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION['saas_pending_user_id'] = $userId;
|
||||
$_SESSION['saas_pending_started_at'] = time();
|
||||
unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']);
|
||||
}
|
||||
|
||||
function saas_pending_tenant_user_id(): ?int
|
||||
{
|
||||
app_start_session();
|
||||
|
||||
$userId = isset($_SESSION['saas_pending_user_id']) ? (int)$_SESSION['saas_pending_user_id'] : 0;
|
||||
$startedAt = isset($_SESSION['saas_pending_started_at']) ? (int)$_SESSION['saas_pending_started_at'] : 0;
|
||||
if ($userId <= 0 || $startedAt <= 0 || $startedAt < time() - 900) {
|
||||
saas_clear_pending_tenant_selection();
|
||||
return null;
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
function saas_clear_pending_tenant_selection(): void
|
||||
{
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
app_start_session();
|
||||
}
|
||||
|
||||
unset($_SESSION['saas_pending_user_id'], $_SESSION['saas_pending_started_at']);
|
||||
}
|
||||
|
||||
function saas_create_auth_token(PDO $pdo, int $userId, string $tokenType, ?int $tenantId, int $ttlMinutes): array
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
@@ -625,11 +797,7 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT
|
||||
t.id AS tenant_id,
|
||||
t.slug AS tenant_slug,
|
||||
t.name AS tenant_name,
|
||||
tm.role
|
||||
'SELECT t.id AS tenant_id
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = ?
|
||||
@@ -641,13 +809,30 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
WHEN \'admin\' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
t.name
|
||||
LIMIT 1'
|
||||
t.name'
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$membership = $stmt->fetch();
|
||||
$tenantIds = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
if ($membership === false) {
|
||||
if ($tenantIds === []) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => ['Fuer dieses Konto ist kein aktiver Mandant verfuegbar.'],
|
||||
];
|
||||
}
|
||||
|
||||
if (count($tenantIds) > 1 && trim($tenantSlug) === '') {
|
||||
return [
|
||||
'ok' => true,
|
||||
'needs_tenant_selection' => true,
|
||||
'user_id' => (int)$user['id'],
|
||||
'memberships' => saas_list_user_memberships($pdo, (int)$user['id']),
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$identity = saas_identity_for_user_tenant($pdo, (int)$user['id'], $tenantIds[0]);
|
||||
if ($identity === null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'errors' => ['Fuer dieses Konto ist kein aktiver Mandant verfuegbar.'],
|
||||
@@ -658,16 +843,7 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'identity' => [
|
||||
'user_id' => (int)$user['id'],
|
||||
'email' => (string)$user['email'],
|
||||
'email_norm' => (string)$user['email_norm'],
|
||||
'display_name' => (string)$user['display_name'],
|
||||
'tenant_id' => (int)$membership['tenant_id'],
|
||||
'tenant_slug' => (string)$membership['tenant_slug'],
|
||||
'tenant_name' => (string)$membership['tenant_name'],
|
||||
'role' => (string)$membership['role'],
|
||||
],
|
||||
'identity' => $identity,
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_domains (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
domain_norm VARCHAR(255) NOT NULL,
|
||||
domain_type VARCHAR(30) NOT NULL DEFAULT 'custom',
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||
verified_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_tenant_domains_domain_norm (domain_norm),
|
||||
KEY idx_tenant_domains_tenant_status (tenant_id, status),
|
||||
CONSTRAINT fk_tenant_domains_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -140,9 +140,9 @@ Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
|
||||
eine PayPal-Alias-Dublette und hinterlaesst keine Datei in `var/uploads`.
|
||||
- CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen.
|
||||
- Golden Master weiterhin gruen mit 104 Assertions.
|
||||
- HTTP-Smoke weiterhin gruen mit 20 sicheren Seiten inklusive Login,
|
||||
- HTTP-Smoke weiterhin gruen mit 21 sicheren Seiten inklusive Login,
|
||||
Registrierung, Passwort-Reset, E-Mail-Verifikation und geschuetzter
|
||||
Mandant-Einstellungen.
|
||||
Mandant-Einstellungen sowie geschuetzter Mandantenauswahl.
|
||||
|
||||
## Bewusste Grenzen
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ Umgesetzte Migrationen:
|
||||
database/migrations/0002_saas_identity_tenants.sql
|
||||
database/migrations/0003_saas_auth_account_fields.sql
|
||||
database/migrations/0004_saas_auth_tokens.sql
|
||||
database/migrations/0005_saas_tenant_resolution.sql
|
||||
```
|
||||
|
||||
Tabellen:
|
||||
@@ -41,6 +42,7 @@ Tabellen:
|
||||
- `users`
|
||||
- `tenant_memberships`
|
||||
- `participants`
|
||||
- `tenant_domains`
|
||||
|
||||
Ergaenzende Skripte:
|
||||
|
||||
@@ -50,6 +52,7 @@ scripts/check-m3-saas-basis.php
|
||||
scripts/check-m3-auth-flow.php
|
||||
scripts/check-m3-settings-flow.php
|
||||
scripts/check-m3-password-email-flow.php
|
||||
scripts/check-m3-tenant-resolution-flow.php
|
||||
```
|
||||
|
||||
Wichtige Regeln:
|
||||
@@ -97,9 +100,12 @@ Vorgang:
|
||||
erzeugen.
|
||||
5. `admin = 1` als Rolle `admin` abbilden; ein konfigurierter erster Benutzer
|
||||
kann Rolle `owner` erhalten.
|
||||
6. Verwaiste `participants` mit nicht mehr vorhandener `legacy_mitarbeiter_id`
|
||||
aus dem Default-Tenant entfernen.
|
||||
|
||||
Der Backfill muss idempotent sein und darf keine bestehenden Legacy-Daten
|
||||
loeschen.
|
||||
loeschen. Geloescht werden nur SaaS-Spiegelungen, deren Legacy-Mitarbeiter in
|
||||
`kl_Mitarbeiter` nicht mehr existiert.
|
||||
|
||||
Ausgefuehrter Dev-Stand:
|
||||
|
||||
@@ -120,6 +126,7 @@ login.php
|
||||
register.php
|
||||
konto.php
|
||||
logout.php
|
||||
mandant-auswahl.php
|
||||
passwort-vergessen.php
|
||||
passwort-zuruecksetzen.php
|
||||
email-verifikation-senden.php
|
||||
@@ -131,6 +138,10 @@ Umgesetzter Umfang:
|
||||
- Registrierung legt Tenant, Owner-User, Default-Settings, Membership und
|
||||
Owner-Participant in einer Transaktion an.
|
||||
- Login prueft `users.password_hash`, aktive Membership und aktiven Tenant.
|
||||
- Hat ein User genau einen aktiven Mandanten, wird dieser direkt in die Session
|
||||
gelegt.
|
||||
- Hat ein User mehrere aktive Mandanten, fuehrt der Login zur
|
||||
Mandantenauswahl.
|
||||
- Logout beendet die neue PHP-Login-Session.
|
||||
- `konto.php` zeigt den aktuellen SaaS-Kontext fuer den angemeldeten User.
|
||||
- `functions.php` akzeptiert eine SaaS-Login-Session als erste Identitaetsquelle,
|
||||
@@ -145,10 +156,28 @@ Umgesetzter Umfang:
|
||||
|
||||
Noch offen im M3-Auth-Scope:
|
||||
|
||||
- Tenant-Aufloesung ueber Subdomain oder Custom Domain.
|
||||
- Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten.
|
||||
- Produktiver Mailversand fuer Reset- und Verifikationslinks.
|
||||
|
||||
## Tenant-Aufloesung
|
||||
|
||||
Entscheidung fuer den Webspace-Betrieb:
|
||||
|
||||
- Keine Wildcard-Subdomains im ersten Schritt.
|
||||
- Primaere App-Adresse ist eine zentrale App-Domain wie `app.kaffeeliste.de`.
|
||||
- Der aktive Mandant wird nach Login ueber `tenant_memberships` und die PHP-
|
||||
Session gesetzt.
|
||||
- Bei genau einem Mandanten wird automatisch weitergeleitet.
|
||||
- Bei mehreren Mandanten nutzt der User `mandant-auswahl.php`.
|
||||
- `tenant_domains` ist vorbereitet fuer spaeter gezielt eingerichtete feste
|
||||
Domains oder Subdomains, aber nicht Voraussetzung fuer den Start.
|
||||
|
||||
Noch offen:
|
||||
|
||||
- `APP_PRIMARY_HOST` in der Zielumgebung setzen.
|
||||
- Produktive Domain-/Zertifikatspruefung fuer einzelne feste Domains
|
||||
definieren.
|
||||
|
||||
## Rollen und Grundeinstellungen
|
||||
|
||||
Umgesetzte Dateien:
|
||||
@@ -183,6 +212,7 @@ Umgesetzter Umfang:
|
||||
- Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt.
|
||||
- Passwort-Reset und E-Mail-Verifikation funktionieren mit Single-Use-Tokens:
|
||||
erfuellt.
|
||||
- Mandantenauswahl funktioniert fuer User mit mehreren Mandanten: erfuellt.
|
||||
- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt.
|
||||
|
||||
## Risiken
|
||||
@@ -209,6 +239,9 @@ Umgesetzter Umfang:
|
||||
10. Migration `0004_saas_auth_tokens.sql` erstellen: erledigt.
|
||||
11. Passwort-Reset und E-Mail-Verifikation bauen: erledigt.
|
||||
12. Password-/E-Mail-Flow-Kontrollskript schreiben: erledigt.
|
||||
13. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
|
||||
14. Tenant-Aufloesung planen:
|
||||
13. Migration `0005_saas_tenant_resolution.sql` erstellen: erledigt.
|
||||
14. Mandantenauswahl und zentrale Session-Aufloesung bauen: erledigt.
|
||||
15. Tenant-Resolution-Kontrollskript schreiben: erledigt.
|
||||
16. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
|
||||
17. Produktiven Mailversand oder Landingpage vorbereiten:
|
||||
naechster Schritt.
|
||||
|
||||
@@ -422,7 +422,8 @@ Schritte:
|
||||
- Login und Logout bauen: erster Flow erledigt.
|
||||
- Passwort-Reset und E-Mail-Verifikation bauen: Dev-Flow mit Single-Use-Tokens
|
||||
erledigt.
|
||||
- Tenant-Aufloesung definieren.
|
||||
- Tenant-Aufloesung definieren: zentrale App-Domain mit Session-Kontext und
|
||||
Mandantenauswahl erledigt; feste Domains vorbereitet, keine Wildcards.
|
||||
- Rollenpruefung zentralisieren: erster Owner/Admin-Check erledigt.
|
||||
- Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt.
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$tenantSlug
|
||||
);
|
||||
|
||||
if ($result['ok'] && !empty($result['needs_tenant_selection'])) {
|
||||
saas_start_pending_tenant_selection((int)$result['user_id']);
|
||||
header('Location: mandant-auswahl.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($result['ok']) {
|
||||
saas_session_login($result['identity']);
|
||||
header('Location: konto.php');
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$pendingUserId = saas_pending_tenant_user_id();
|
||||
if ($pendingUserId === null) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$memberships = saas_list_user_memberships($pdo, $pendingUserId);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
|
||||
$tenantId = isset($_POST['tenant_id']) ? (int)$_POST['tenant_id'] : 0;
|
||||
$identity = $tenantId > 0 ? saas_identity_for_user_tenant($pdo, $pendingUserId, $tenantId) : null;
|
||||
|
||||
if ($identity === null) {
|
||||
$errors[] = 'Dieser Mandant ist fuer dein Konto nicht verfuegbar.';
|
||||
} else {
|
||||
saas_session_login($identity);
|
||||
header('Location: konto.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>Mandant auswaehlen</h2>
|
||||
|
||||
<?php if ($errors !== []): ?>
|
||||
<div class="hint-box error">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<p><?php echo saas_html($error); ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($memberships === []): ?>
|
||||
<div class="hint-box error"><p>Fuer dieses Konto ist kein aktiver Mandant verfuegbar.</p></div>
|
||||
<?php else: ?>
|
||||
<form method="post" action="mandant-auswahl.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<label for="tenant_id">Mandant</label>
|
||||
<select name="tenant_id" id="tenant_id" required>
|
||||
<?php foreach ($memberships as $membership): ?>
|
||||
<option value="<?php echo saas_html($membership['tenant_id']); ?>">
|
||||
<?php echo saas_html($membership['tenant_name']); ?>
|
||||
(<?php echo saas_html($membership['role']); ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Weiter</button></li>
|
||||
<li><a href="logout.php" class="button alt">Abbrechen</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
@@ -89,6 +89,7 @@ $ownerEmailNorm = m3_email_norm(getenv('M3_DEFAULT_TENANT_OWNER_EMAIL') ?: null)
|
||||
$stats = [
|
||||
'applied_migrations' => count($applied),
|
||||
'participants' => 0,
|
||||
'stale_participants_removed' => 0,
|
||||
'users' => 0,
|
||||
'memberships' => 0,
|
||||
];
|
||||
@@ -154,7 +155,8 @@ try {
|
||||
email = VALUES(email),
|
||||
email_norm = VALUES(email_norm),
|
||||
paypal_name = VALUES(paypal_name),
|
||||
active = VALUES(active)'
|
||||
active = VALUES(active),
|
||||
legacy_mitarbeiter_id = VALUES(legacy_mitarbeiter_id)'
|
||||
);
|
||||
$upsertMembership = $pdo->prepare(
|
||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at)
|
||||
@@ -206,6 +208,18 @@ try {
|
||||
$stats['memberships']++;
|
||||
}
|
||||
|
||||
$removeStaleParticipants = $pdo->prepare(
|
||||
'DELETE p
|
||||
FROM participants p
|
||||
LEFT JOIN kl_Mitarbeiter m
|
||||
ON m.MitarbeiterID = p.legacy_mitarbeiter_id
|
||||
WHERE p.tenant_id = ?
|
||||
AND p.legacy_mitarbeiter_id IS NOT NULL
|
||||
AND m.MitarbeiterID IS NULL'
|
||||
);
|
||||
$removeStaleParticipants->execute([$tenantId]);
|
||||
$stats['stale_participants_removed'] = $removeStaleParticipants->rowCount();
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
@@ -217,5 +231,6 @@ echo "M3 default tenant backfill complete.\n";
|
||||
echo "Tenant: {$tenantSlug}\n";
|
||||
echo "Applied migrations: {$stats['applied_migrations']}\n";
|
||||
echo "Participants mirrored: {$stats['participants']}\n";
|
||||
echo "Stale participants removed: {$stats['stale_participants_removed']}\n";
|
||||
echo "Users upserted: {$stats['users']}\n";
|
||||
echo "Memberships upserted: {$stats['memberships']}\n";
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/dev-db.php';
|
||||
require __DIR__ . '/../app/saas-auth.php';
|
||||
|
||||
function tenant_resolution_assert(string $label, bool $condition, array &$failures, int &$passes): void
|
||||
{
|
||||
if ($condition) {
|
||||
$passes++;
|
||||
echo "PASS {$label}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$failures[] = $label;
|
||||
echo "FAIL {$label}\n";
|
||||
}
|
||||
|
||||
function tenant_resolution_cleanup(PDO $pdo, array $slugs, string $emailNorm): void
|
||||
{
|
||||
foreach ($slugs as $slug) {
|
||||
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
|
||||
$stmt->execute([$slug]);
|
||||
$tenantId = $stmt->fetchColumn();
|
||||
|
||||
if ($tenantId === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantId = (int)$tenantId;
|
||||
$pdo->prepare('DELETE FROM tenant_domains WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
|
||||
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM users WHERE email_norm = ?')->execute([$emailNorm]);
|
||||
}
|
||||
|
||||
$pdo = dev_pdo();
|
||||
dev_apply_schema($pdo);
|
||||
|
||||
$slugs = ['m3-tenant-choice-a', 'm3-tenant-choice-b'];
|
||||
$email = 'm3-tenant-choice@test.local';
|
||||
$emailNorm = saas_email_norm($email);
|
||||
$password = 'M3-tenant-choice-123';
|
||||
$failures = [];
|
||||
$passes = 0;
|
||||
|
||||
tenant_resolution_cleanup($pdo, $slugs, $emailNorm);
|
||||
|
||||
$first = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => 'M3 Tenant Choice A',
|
||||
'tenant_slug' => $slugs[0],
|
||||
'display_name' => 'M3 Tenant Choice',
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'password_confirm' => $password,
|
||||
]);
|
||||
$second = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => 'M3 Tenant Choice B',
|
||||
'tenant_slug' => $slugs[1],
|
||||
'display_name' => 'M3 Tenant Choice',
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'password_confirm' => $password,
|
||||
]);
|
||||
|
||||
tenant_resolution_assert('first registration succeeds', $first['ok'] === true, $failures, $passes);
|
||||
tenant_resolution_assert('second registration succeeds', $second['ok'] === true, $failures, $passes);
|
||||
|
||||
if ($first['ok'] && $second['ok']) {
|
||||
$userId = (int)$first['identity']['user_id'];
|
||||
$memberships = saas_list_user_memberships($pdo, $userId);
|
||||
tenant_resolution_assert('user has two memberships', count($memberships) === 2, $failures, $passes);
|
||||
|
||||
$genericLogin = saas_authenticate($pdo, $email, $password);
|
||||
tenant_resolution_assert('generic login succeeds', $genericLogin['ok'] === true, $failures, $passes);
|
||||
tenant_resolution_assert('generic login needs tenant selection', !empty($genericLogin['needs_tenant_selection']), $failures, $passes);
|
||||
tenant_resolution_assert('generic login exposes memberships', count($genericLogin['memberships'] ?? []) === 2, $failures, $passes);
|
||||
|
||||
$specificLogin = saas_authenticate($pdo, $email, $password, $slugs[1]);
|
||||
tenant_resolution_assert('specific login succeeds', $specificLogin['ok'] === true, $failures, $passes);
|
||||
tenant_resolution_assert(
|
||||
'specific login selects requested tenant',
|
||||
($specificLogin['identity']['tenant_slug'] ?? null) === $slugs[1],
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$identity = saas_identity_for_user_tenant($pdo, $userId, (int)$second['identity']['tenant_id']);
|
||||
tenant_resolution_assert('tenant selection identity exists', $identity !== null, $failures, $passes);
|
||||
if ($identity !== null) {
|
||||
tenant_resolution_assert('tenant selection returns requested tenant', $identity['tenant_slug'] === $slugs[1], $failures, $passes);
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO tenant_domains (tenant_id, domain, domain_norm, domain_type, status, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())'
|
||||
)->execute([(int)$second['identity']['tenant_id'], 'kaffee.example.test', 'kaffee.example.test', 'custom', 'active']);
|
||||
|
||||
$domainTenant = saas_resolve_tenant_domain($pdo, 'kaffee.example.test:443');
|
||||
tenant_resolution_assert('fixed domain resolves tenant', $domainTenant !== null, $failures, $passes);
|
||||
if ($domainTenant !== null) {
|
||||
tenant_resolution_assert('fixed domain returns requested tenant', $domainTenant['tenant_slug'] === $slugs[1], $failures, $passes);
|
||||
}
|
||||
}
|
||||
|
||||
tenant_resolution_cleanup($pdo, $slugs, $emailNorm);
|
||||
|
||||
if ($failures !== []) {
|
||||
echo "\nM3 tenant resolution check failed with " . count($failures) . " failure(s):\n";
|
||||
foreach ($failures as $failure) {
|
||||
echo "- {$failure}\n";
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\nM3 tenant resolution check passed with {$passes} assertions.\n";
|
||||
@@ -66,6 +66,11 @@ $checks = [
|
||||
'path' => 'passwort-vergessen.php',
|
||||
'contains' => ['Passwort vergessen', 'Link vorbereiten'],
|
||||
],
|
||||
[
|
||||
'label' => 'Mandantenauswahl Login-Schutz',
|
||||
'path' => 'mandant-auswahl.php',
|
||||
'contains' => ['Login'],
|
||||
],
|
||||
[
|
||||
'label' => 'Passwort zuruecksetzen Invalid',
|
||||
'path' => 'passwort-zuruecksetzen.php',
|
||||
|
||||
Reference in New Issue
Block a user