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:
2026-07-13 20:04:20 +02:00
parent 8ab4a8a9bb
commit 9f2e1bdfb1
10 changed files with 475 additions and 28 deletions
+196 -20
View File
@@ -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' => [],
];
}