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);
|
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
|
function saas_parse_money_cents(mixed $value): ?int
|
||||||
{
|
{
|
||||||
$normalized = trim((string)$value);
|
$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_user_id'] = (int)$identity['user_id'];
|
||||||
$_SESSION['saas_tenant_id'] = (int)$identity['tenant_id'];
|
$_SESSION['saas_tenant_id'] = (int)$identity['tenant_id'];
|
||||||
$_SESSION['saas_role'] = (string)$identity['role'];
|
$_SESSION['saas_role'] = (string)$identity['role'];
|
||||||
|
saas_clear_pending_tenant_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saas_logout(): void
|
function saas_logout(): void
|
||||||
{
|
{
|
||||||
app_start_session();
|
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
|
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;
|
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
|
function saas_create_auth_token(PDO $pdo, int $userId, string $tokenType, ?int $tenantId, int $ttlMinutes): array
|
||||||
{
|
{
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
@@ -625,11 +797,7 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'SELECT
|
'SELECT t.id AS tenant_id
|
||||||
t.id AS tenant_id,
|
|
||||||
t.slug AS tenant_slug,
|
|
||||||
t.name AS tenant_name,
|
|
||||||
tm.role
|
|
||||||
FROM tenant_memberships tm
|
FROM tenant_memberships tm
|
||||||
JOIN tenants t ON t.id = tm.tenant_id
|
JOIN tenants t ON t.id = tm.tenant_id
|
||||||
WHERE tm.user_id = ?
|
WHERE tm.user_id = ?
|
||||||
@@ -641,13 +809,30 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
|||||||
WHEN \'admin\' THEN 2
|
WHEN \'admin\' THEN 2
|
||||||
ELSE 3
|
ELSE 3
|
||||||
END,
|
END,
|
||||||
t.name
|
t.name'
|
||||||
LIMIT 1'
|
|
||||||
);
|
);
|
||||||
$stmt->execute($params);
|
$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 [
|
return [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'errors' => ['Fuer dieses Konto ist kein aktiver Mandant verfuegbar.'],
|
'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 [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'identity' => [
|
'identity' => $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'],
|
|
||||||
],
|
|
||||||
'errors' => [],
|
'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`.
|
eine PayPal-Alias-Dublette und hinterlaesst keine Datei in `var/uploads`.
|
||||||
- CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen.
|
- CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen.
|
||||||
- Golden Master weiterhin gruen mit 104 Assertions.
|
- 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
|
Registrierung, Passwort-Reset, E-Mail-Verifikation und geschuetzter
|
||||||
Mandant-Einstellungen.
|
Mandant-Einstellungen sowie geschuetzter Mandantenauswahl.
|
||||||
|
|
||||||
## Bewusste Grenzen
|
## Bewusste Grenzen
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Umgesetzte Migrationen:
|
|||||||
database/migrations/0002_saas_identity_tenants.sql
|
database/migrations/0002_saas_identity_tenants.sql
|
||||||
database/migrations/0003_saas_auth_account_fields.sql
|
database/migrations/0003_saas_auth_account_fields.sql
|
||||||
database/migrations/0004_saas_auth_tokens.sql
|
database/migrations/0004_saas_auth_tokens.sql
|
||||||
|
database/migrations/0005_saas_tenant_resolution.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
Tabellen:
|
Tabellen:
|
||||||
@@ -41,6 +42,7 @@ Tabellen:
|
|||||||
- `users`
|
- `users`
|
||||||
- `tenant_memberships`
|
- `tenant_memberships`
|
||||||
- `participants`
|
- `participants`
|
||||||
|
- `tenant_domains`
|
||||||
|
|
||||||
Ergaenzende Skripte:
|
Ergaenzende Skripte:
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ scripts/check-m3-saas-basis.php
|
|||||||
scripts/check-m3-auth-flow.php
|
scripts/check-m3-auth-flow.php
|
||||||
scripts/check-m3-settings-flow.php
|
scripts/check-m3-settings-flow.php
|
||||||
scripts/check-m3-password-email-flow.php
|
scripts/check-m3-password-email-flow.php
|
||||||
|
scripts/check-m3-tenant-resolution-flow.php
|
||||||
```
|
```
|
||||||
|
|
||||||
Wichtige Regeln:
|
Wichtige Regeln:
|
||||||
@@ -97,9 +100,12 @@ Vorgang:
|
|||||||
erzeugen.
|
erzeugen.
|
||||||
5. `admin = 1` als Rolle `admin` abbilden; ein konfigurierter erster Benutzer
|
5. `admin = 1` als Rolle `admin` abbilden; ein konfigurierter erster Benutzer
|
||||||
kann Rolle `owner` erhalten.
|
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
|
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:
|
Ausgefuehrter Dev-Stand:
|
||||||
|
|
||||||
@@ -120,6 +126,7 @@ login.php
|
|||||||
register.php
|
register.php
|
||||||
konto.php
|
konto.php
|
||||||
logout.php
|
logout.php
|
||||||
|
mandant-auswahl.php
|
||||||
passwort-vergessen.php
|
passwort-vergessen.php
|
||||||
passwort-zuruecksetzen.php
|
passwort-zuruecksetzen.php
|
||||||
email-verifikation-senden.php
|
email-verifikation-senden.php
|
||||||
@@ -131,6 +138,10 @@ Umgesetzter Umfang:
|
|||||||
- Registrierung legt Tenant, Owner-User, Default-Settings, Membership und
|
- Registrierung legt Tenant, Owner-User, Default-Settings, Membership und
|
||||||
Owner-Participant in einer Transaktion an.
|
Owner-Participant in einer Transaktion an.
|
||||||
- Login prueft `users.password_hash`, aktive Membership und aktiven Tenant.
|
- 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.
|
- Logout beendet die neue PHP-Login-Session.
|
||||||
- `konto.php` zeigt den aktuellen SaaS-Kontext fuer den angemeldeten User.
|
- `konto.php` zeigt den aktuellen SaaS-Kontext fuer den angemeldeten User.
|
||||||
- `functions.php` akzeptiert eine SaaS-Login-Session als erste Identitaetsquelle,
|
- `functions.php` akzeptiert eine SaaS-Login-Session als erste Identitaetsquelle,
|
||||||
@@ -145,10 +156,28 @@ Umgesetzter Umfang:
|
|||||||
|
|
||||||
Noch offen im M3-Auth-Scope:
|
Noch offen im M3-Auth-Scope:
|
||||||
|
|
||||||
- Tenant-Aufloesung ueber Subdomain oder Custom Domain.
|
|
||||||
- Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten.
|
- Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten.
|
||||||
- Produktiver Mailversand fuer Reset- und Verifikationslinks.
|
- 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
|
## Rollen und Grundeinstellungen
|
||||||
|
|
||||||
Umgesetzte Dateien:
|
Umgesetzte Dateien:
|
||||||
@@ -183,6 +212,7 @@ Umgesetzter Umfang:
|
|||||||
- Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt.
|
- Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt.
|
||||||
- Passwort-Reset und E-Mail-Verifikation funktionieren mit Single-Use-Tokens:
|
- Passwort-Reset und E-Mail-Verifikation funktionieren mit Single-Use-Tokens:
|
||||||
erfuellt.
|
erfuellt.
|
||||||
|
- Mandantenauswahl funktioniert fuer User mit mehreren Mandanten: erfuellt.
|
||||||
- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt.
|
- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt.
|
||||||
|
|
||||||
## Risiken
|
## Risiken
|
||||||
@@ -209,6 +239,9 @@ Umgesetzter Umfang:
|
|||||||
10. Migration `0004_saas_auth_tokens.sql` erstellen: erledigt.
|
10. Migration `0004_saas_auth_tokens.sql` erstellen: erledigt.
|
||||||
11. Passwort-Reset und E-Mail-Verifikation bauen: erledigt.
|
11. Passwort-Reset und E-Mail-Verifikation bauen: erledigt.
|
||||||
12. Password-/E-Mail-Flow-Kontrollskript schreiben: erledigt.
|
12. Password-/E-Mail-Flow-Kontrollskript schreiben: erledigt.
|
||||||
13. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
|
13. Migration `0005_saas_tenant_resolution.sql` erstellen: erledigt.
|
||||||
14. Tenant-Aufloesung planen:
|
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.
|
naechster Schritt.
|
||||||
|
|||||||
@@ -422,7 +422,8 @@ Schritte:
|
|||||||
- Login und Logout bauen: erster Flow erledigt.
|
- Login und Logout bauen: erster Flow erledigt.
|
||||||
- Passwort-Reset und E-Mail-Verifikation bauen: Dev-Flow mit Single-Use-Tokens
|
- Passwort-Reset und E-Mail-Verifikation bauen: Dev-Flow mit Single-Use-Tokens
|
||||||
erledigt.
|
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.
|
- Rollenpruefung zentralisieren: erster Owner/Admin-Check erledigt.
|
||||||
- Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt.
|
- Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$tenantSlug
|
$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']) {
|
if ($result['ok']) {
|
||||||
saas_session_login($result['identity']);
|
saas_session_login($result['identity']);
|
||||||
header('Location: konto.php');
|
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 = [
|
$stats = [
|
||||||
'applied_migrations' => count($applied),
|
'applied_migrations' => count($applied),
|
||||||
'participants' => 0,
|
'participants' => 0,
|
||||||
|
'stale_participants_removed' => 0,
|
||||||
'users' => 0,
|
'users' => 0,
|
||||||
'memberships' => 0,
|
'memberships' => 0,
|
||||||
];
|
];
|
||||||
@@ -154,7 +155,8 @@ try {
|
|||||||
email = VALUES(email),
|
email = VALUES(email),
|
||||||
email_norm = VALUES(email_norm),
|
email_norm = VALUES(email_norm),
|
||||||
paypal_name = VALUES(paypal_name),
|
paypal_name = VALUES(paypal_name),
|
||||||
active = VALUES(active)'
|
active = VALUES(active),
|
||||||
|
legacy_mitarbeiter_id = VALUES(legacy_mitarbeiter_id)'
|
||||||
);
|
);
|
||||||
$upsertMembership = $pdo->prepare(
|
$upsertMembership = $pdo->prepare(
|
||||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at)
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at)
|
||||||
@@ -206,6 +208,18 @@ try {
|
|||||||
$stats['memberships']++;
|
$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();
|
$pdo->commit();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
@@ -217,5 +231,6 @@ echo "M3 default tenant backfill complete.\n";
|
|||||||
echo "Tenant: {$tenantSlug}\n";
|
echo "Tenant: {$tenantSlug}\n";
|
||||||
echo "Applied migrations: {$stats['applied_migrations']}\n";
|
echo "Applied migrations: {$stats['applied_migrations']}\n";
|
||||||
echo "Participants mirrored: {$stats['participants']}\n";
|
echo "Participants mirrored: {$stats['participants']}\n";
|
||||||
|
echo "Stale participants removed: {$stats['stale_participants_removed']}\n";
|
||||||
echo "Users upserted: {$stats['users']}\n";
|
echo "Users upserted: {$stats['users']}\n";
|
||||||
echo "Memberships upserted: {$stats['memberships']}\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',
|
'path' => 'passwort-vergessen.php',
|
||||||
'contains' => ['Passwort vergessen', 'Link vorbereiten'],
|
'contains' => ['Passwort vergessen', 'Link vorbereiten'],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Mandantenauswahl Login-Schutz',
|
||||||
|
'path' => 'mandant-auswahl.php',
|
||||||
|
'contains' => ['Login'],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'Passwort zuruecksetzen Invalid',
|
'label' => 'Passwort zuruecksetzen Invalid',
|
||||||
'path' => 'passwort-zuruecksetzen.php',
|
'path' => 'passwort-zuruecksetzen.php',
|
||||||
|
|||||||
Reference in New Issue
Block a user