Fix: rollenlose aktive Nutzer -> Backfill-Command + Import-Nachlauf

Problem: Ein eingeloggter Nutzer ohne admin/editor/customer-Rolle wird per
redirectUsersTo auf die Startseite geleitet und haengt dort fest. Ursache
sind Alt-Konten, deren Rollen-Zuweisung nie lief (frueher Import bzw. ein
nach dem User-Insert fehlgeschlagener Rollen-Sync).

- Neues Command users:backfill-portal-role (--role, --dry-run): weist aktiven,
  komplett rollenlosen Nutzern die customer-Rolle zu. api-only-Konten haben
  bereits eine Rolle und bleiben unangetastet; inaktive werden uebersprungen.
- legacy:import ruft den Backfill nach dem User-Schritt automatisch auf
  (self-heal, da updateOrCreate + Rollen-Sync nicht atomar sind).

Analyse-Ergebnis nebenbei: In der aktuellen DB war nur genau 1 echter Fall
(bereits gefixt); die zuvor genannten ~167 waren fast alle api-only (gewollt
ohne Portal-Zugriff). Command laeuft daher jetzt sauber auf 0.

Test: BackfillPortalRoleTest (customer-Zuweisung, Rollen-Erhalt bei
customer/api-only, Ueberspringen Inaktiver, dry-run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-02 09:23:56 +00:00
parent dbbf8a4487
commit 7c5518dc97
3 changed files with 118 additions and 0 deletions

View file

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Auth\UserRolePermissionSyncService;
use Illuminate\Console\Command;
/**
* Weist aktiven, komplett rollenlosen Nutzern eine Portal-Rolle zu.
*
* Hintergrund: Guest-/Auth-Redirects lenken einen eingeloggten Nutzer ohne
* admin/editor/customer-Rolle auf die Startseite (bootstrap/app.php →
* redirectUsersTo). Legacy-Importe oder ein fehlgeschlagener Rollen-Sync nach
* dem User-Insert können solche „hängenden" Konten erzeugen. Dieser Nachlauf
* heilt sie. api-only-Konten besitzen bereits eine Rolle und werden bewusst
* NICHT angefasst (kein Portal-Zugriff gewollt).
*/
class BackfillPortalRole extends Command
{
protected $signature = 'users:backfill-portal-role
{--role=customer : Zuzuweisende Rolle}
{--dry-run : Nur zählen, nichts schreiben}';
protected $description = 'Weist aktiven, rollenlosen Nutzern eine Portal-Rolle (Standard: customer) zu.';
public function handle(UserRolePermissionSyncService $roleSync): int
{
$role = (string) $this->option('role');
$dryRun = (bool) $this->option('dry-run');
$ids = User::query()
->where('is_active', true)
->whereDoesntHave('roles')
->pluck('id');
if ($ids->isEmpty()) {
$this->info('Keine aktiven rollenlosen Nutzer gefunden – nichts zu tun.');
return self::SUCCESS;
}
if ($dryRun) {
$this->warn("[DRY-RUN] {$ids->count()} aktive rollenlose Nutzer würden die Rolle '{$role}' erhalten.");
return self::SUCCESS;
}
$assigned = 0;
foreach ($ids->chunk(500) as $chunk) {
User::query()->whereIn('id', $chunk)->get()->each(function (User $user) use ($roleSync, $role, &$assigned): void {
$roleSync->assignRoleAndSyncPermissions($user, $role);
$assigned++;
});
}
$this->info("{$assigned} aktiven rollenlosen Nutzern die Rolle '{$role}' zugewiesen.");
return self::SUCCESS;
}
}

View file

@ -120,6 +120,16 @@ class ImportLegacyData extends Command
}
}
// Selbstheilung: Ein User-Insert (updateOrCreate) und die anschließende
// Rollen-Zuweisung sind nicht atomar – schlägt der Rollen-Sync nach dem
// Insert fehl, bliebe ein aktives, rollenloses Konto zurück (hängt beim
// Login auf der Startseite). Nach einem User-Import daher absichern.
if (! $isDryRun && in_array('users', $steps, true)) {
$this->newLine();
$this->info('▶ Nachlauf: aktive rollenlose Nutzer absichern');
$this->call('users:backfill-portal-role');
}
$total = round(microtime(true) - $totalStart, 1);
$this->newLine();
$this->info("Import abgeschlossen in {$total}s.");

View file

@ -0,0 +1,46 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
test('assigns customer role to active roleless users', function () {
$stuck = User::factory()->create(['is_active' => true]);
$this->artisan('users:backfill-portal-role')->assertSuccessful();
expect($stuck->fresh()->hasRole('customer'))->toBeTrue();
});
test('leaves users that already have a role untouched', function () {
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$apiOnly = User::factory()->create(['is_active' => true]);
$apiOnly->assignRole('api-only');
$this->artisan('users:backfill-portal-role')->assertSuccessful();
expect($customer->fresh()->getRoleNames()->all())->toBe(['customer'])
->and($apiOnly->fresh()->getRoleNames()->all())->toBe(['api-only']);
});
test('skips inactive roleless users', function () {
$inactive = User::factory()->create(['is_active' => false]);
$this->artisan('users:backfill-portal-role')->assertSuccessful();
expect($inactive->fresh()->roles()->exists())->toBeFalse();
});
test('dry-run assigns nothing', function () {
$stuck = User::factory()->create(['is_active' => true]);
$this->artisan('users:backfill-portal-role', ['--dry-run' => true])->assertSuccessful();
expect($stuck->fresh()->roles()->exists())->toBeFalse();
});