presseportale/app/Services/Import/UserImporter.php
Kevin Adametz 40783192d0 security: Rechteausweitung schließen & Admin-Panel feingranular gaten (K1, H1, H2, M8)
- users:manage/roles:manage auf allen User-/Rollen-Masken erzwungen;
  is_super_admin aus $fillable entfernt und nur per autorisiertem
  forceFill setzbar; zentraler Gate::before-Super-Admin-Kurzschluss (K1/M8).
- Nicht-Super-Admins können Super-Admin-Konten nicht mehr bearbeiten (Fix#2);
  Impersonation mit Rangprüfung, keine Token-Erstellung während Impersonation (H1).
- Destruktive/verwaltende Admin-Aktionen (CRM/Billing/Compliance/Struktur)
  hängen an neuen admin-only Permissions; Editor behält reine Content-Rechte (H2).
- Kanonischer Permission-Katalog (App\Authorization\Permission) als Single
  Source of Truth für Seeder und Monitoring.

Deployment: `php artisan db:seed --class=RolesAndPermissionsSeeder` auf allen
Umgebungen ausführen (neue Permissions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:48:50 +00:00

276 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Import;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Models\LegacyImportMap;
use App\Models\Profile;
use App\Models\User;
use App\Services\Auth\UserRolePermissionSyncService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class UserImporter
{
/**
* Legacy-Gruppen → Spatie-Rollen.
*
* Achtung: Die Alt-Gruppe 2 hieß dort „editor", meinte aber die normale
* Self-Publisher-Masse (Kunden, die ihre eigenen PMs einstellen). Im neuen
* Rollenmodell ist `editor` ein Redaktions-/Staff-Recht MIT Admin-Panel-
* Zugriff (canAccessAdmin). Legacy-Publisher gehören daher auf `customer`
* (nur „Mein Bereich"), nicht auf `editor`. `editor` wird ausschließlich
* manuell an echtes Personal vergeben.
*/
private const GROUP_ROLE_MAP = [
1 => 'admin',
2 => 'customer',
3 => 'api-only',
4 => 'customer',
];
private const DEFAULT_ROLE = 'customer';
private const CHUNK_SIZE = 500;
/** Registration-Type-Mapping aus Legacy-Wert */
private const REG_TYPE_MAP = [
'agency' => RegistrationType::Agency,
'company' => RegistrationType::Company,
'apiuser' => RegistrationType::ApiUser,
];
public function __construct(
private readonly UserRolePermissionSyncService $roleSync,
) {}
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
$conn = $ctx->connection;
$portal = $ctx->portalEnum;
$legacyPortal = $ctx->legacyPortalValue();
// Lade alle Gruppen-Zuordnungen in Memory (klein genug)
$userGroups = DB::connection($conn)
->table('sf_guard_user_group')
->get()
->groupBy('user_id')
->map(fn ($rows) => $rows->pluck('group_id')->first());
DB::connection($conn)
->table('sf_guard_user')
->join('sf_guard_user_profile', 'sf_guard_user.id', '=', 'sf_guard_user_profile.user_id')
->select([
'sf_guard_user.id as legacy_id',
'sf_guard_user.username',
'sf_guard_user.is_active',
'sf_guard_user.is_super_admin',
'sf_guard_user.last_login',
'sf_guard_user.ip_address',
'sf_guard_user.created_at',
'sf_guard_user.updated_at',
'sf_guard_user_profile.email',
'sf_guard_user_profile.salutation_id',
'sf_guard_user_profile.title',
'sf_guard_user_profile.first_name',
'sf_guard_user_profile.last_name',
'sf_guard_user_profile.address',
'sf_guard_user_profile.country_id',
'sf_guard_user_profile.phone',
'sf_guard_user_profile.birthdate',
'sf_guard_user_profile.language',
'sf_guard_user_profile.backlink_url',
'sf_guard_user_profile.show_stats',
'sf_guard_user_profile.validation_date',
'sf_guard_user_profile.contract_date',
'sf_guard_user_profile.registration_type',
'sf_guard_user_profile.validate',
'sf_guard_user_profile.tax_id_number',
'sf_guard_user_profile.tax_exempt',
'sf_guard_user_profile.tax_exempt_reason',
'sf_guard_user_profile.disable_footer_code',
])
->orderBy('sf_guard_user.id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $portal, $legacyPortal, $userGroups): void {
foreach ($rows as $row) {
try {
$this->importRow($row, $ctx, $result, $portal, $legacyPortal, $userGroups);
} catch (\Throwable $e) {
$result->addError("User legacy_id={$row->legacy_id}: {$e->getMessage()}");
}
}
});
return $result;
}
private function importRow(
object $row,
ImportContext $ctx,
ImportResult $result,
Portal $portal,
string $legacyPortal,
Collection $userGroups,
): void {
// E-Mail-Adresse ist Pflicht
$email = trim((string) $row->email);
if (blank($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$result->incrementSkipped();
return;
}
// Idempotenz-Check via legacy_import_map
$alreadyImported = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $row->legacy_id)
->exists();
if ($alreadyImported && ! $ctx->force) {
$result->incrementSkipped();
return;
}
if ($ctx->dryRun) {
$result->incrementImported();
return;
}
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
if (blank($name)) {
$name = $row->username;
}
$language = in_array($row->language, ['de', 'en']) ? $row->language : 'de';
$regType = self::REG_TYPE_MAP[$row->registration_type] ?? RegistrationType::ExistingLegacy;
$user = User::withoutTimestamps(function () use ($email, $name, $portal, $regType, $language, $row, $legacyPortal): User {
$user = User::query()->updateOrCreate(
['email' => $email],
[
'name' => $name,
'portal' => $portal->value,
'registration_type' => $regType->value,
'language' => $language,
'is_active' => (bool) $row->is_active,
'last_login_at' => $row->last_login ?: null,
'last_login_ip' => $row->ip_address ?: null,
'legacy_portal' => $legacyPortal,
'legacy_id' => $row->legacy_id,
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
// Kein Passwort – User erhält Go-Live-Reset-Mail (D-09)
]
);
// is_super_admin ist nicht mass-assignable (siehe User::$fillable) –
// im vertrauenswürdigen Import explizit per forceFill setzen.
$user->forceFill(['is_super_admin' => (bool) $row->is_super_admin])->save();
return $user;
});
// Rolle zuweisen
$groupId = $userGroups->get($row->legacy_id);
$roleName = self::GROUP_ROLE_MAP[$groupId] ?? self::DEFAULT_ROLE;
$this->roleSync->assignRoleAndSyncPermissions($user, $roleName);
Profile::query()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $this->mapSalutation((int) ($row->salutation_id ?? 0)),
'title' => $this->cleanText($row->title ?? null, 80),
'first_name' => $this->cleanText($row->first_name ?? null, 80),
'last_name' => $this->cleanText($row->last_name ?? null, 80),
'phone' => $this->cleanText($row->phone ?? null, 40),
'address' => $this->cleanText($row->address ?? null, 1000),
'country_code' => $this->mapCountry((int) ($row->country_id ?? 0)),
'birthdate' => $this->validDateOrNull($row->birthdate ?? null),
'backlink_url' => $this->cleanText($row->backlink_url ?? null, 255),
'show_stats' => (bool) ($row->show_stats ?? false),
'validation_date' => $this->validDateOrNull($row->validation_date ?? null),
'contract_date' => $this->validDateOrNull($row->contract_date ?? null),
'validate_token' => $this->cleanText($row->validate ?? null, 64),
'tax_id_number' => $this->cleanText($row->tax_id_number ?? null, 255),
'tax_exempt' => (bool) ($row->tax_exempt ?? false),
'tax_exempt_reason' => $this->cleanText($row->tax_exempt_reason ?? null, 1000),
'disable_footer_code' => (bool) ($row->disable_footer_code ?? false),
]
);
// Import-Map eintragen
LegacyImportMap::query()->updateOrCreate(
[
'legacy_portal' => $legacyPortal,
'legacy_table' => 'sf_guard_user',
'legacy_id' => $row->legacy_id,
],
[
'target_table' => 'users',
'target_id' => $user->id,
'imported_at' => now(),
]
);
if ($alreadyImported) {
$result->incrementUpdated();
} else {
$result->incrementImported();
}
}
private function mapSalutation(int $salutationId): ?string
{
return match ($salutationId) {
1 => 'mr',
2 => 'mrs',
3 => 'none',
default => null,
};
}
private function mapCountry(int $countryId): ?string
{
return match ($countryId) {
177 => 'DE',
80 => 'AT',
196 => 'CH',
115 => 'LI',
117 => 'LU',
21 => 'BE',
2 => 'NL',
165 => 'FR',
30 => 'GB',
229 => 'US',
default => null,
};
}
private function cleanText(?string $value, int $maxLength): ?string
{
if (blank($value)) {
return null;
}
$clean = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$clean = preg_replace('/[\x00-\x1F\x7F\xC2\xA0]/u', ' ', $clean) ?? $clean;
$clean = trim((string) $clean);
return blank($clean) ? null : mb_substr($clean, 0, $maxLength);
}
private function validDateOrNull(mixed $value): ?string
{
if (blank($value) || str_starts_with((string) $value, '0000-00-00')) {
return null;
}
return (string) $value;
}
}