diff --git a/app/Livewire/Admin/Datacenters.php b/app/Livewire/Admin/Datacenters.php index b91a6ac..7160efb 100644 --- a/app/Livewire/Admin/Datacenters.php +++ b/app/Livewire/Admin/Datacenters.php @@ -21,10 +21,14 @@ class Datacenters extends Component public function save(): void { + // Normalize before validating so the unique check matches how the row is + // stored — otherwise `FSN` passes the rule but the lowercased insert collides. + $this->code = strtolower(trim($this->code)); + $data = $this->validate(); Datacenter::create([ - 'code' => strtolower($data['code']), + 'code' => $data['code'], 'name' => $data['name'], 'location' => $data['location'] ?: null, 'active' => true, diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 1d9379c..1ae7df5 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -7,8 +7,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use RuntimeException; class Customer extends Model { @@ -32,20 +34,49 @@ class Customer extends Model return $this->hasMany(Instance::class); } - /** Find or create the portal login account for this customer and link it. */ + /** + * Find or create the portal login account for this customer and link it. + * Race-safe against the unique email index, and never links an operator + * account — that would carry admin authorization into an impersonated + * "customer" session. + */ public function ensureUser(): User { if ($this->user) { return $this->user; } - $user = User::query()->firstOrCreate( - ['email' => $this->email], - ['name' => $this->name, 'password' => Hash::make(Str::random(40)), 'is_admin' => false], - ); + if (($existing = User::query()->where('email', $this->email)->first()) !== null) { + $this->assertNotAdmin($existing); + $this->update(['user_id' => $existing->id]); + + return $existing; + } + + try { + $user = User::query()->create([ + 'email' => $this->email, + 'name' => $this->name, + 'password' => Hash::make(Str::random(40)), + 'is_admin' => false, + ]); + } catch (UniqueConstraintViolationException) { + // Concurrent first-time creation — re-fetch and link the winner. + $user = User::query()->where('email', $this->email)->firstOrFail(); + $this->assertNotAdmin($user); + } $this->update(['user_id' => $user->id]); return $user->refresh(); } + + private function assertNotAdmin(User $user): void + { + if ($user->is_admin) { + throw new RuntimeException( + "Refusing to link admin account {$user->email} as a portal login for customer {$this->id}.", + ); + } + } } diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 271e87c..1d2d848 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -13,10 +13,13 @@ {{-- Admin impersonation banner — visible on every portal page until the admin returns. --}}
{{ __('impersonate.banner', ['name' => auth()->user()->name]) }} - - - {{ __('impersonate.leave') }} - +
+ @csrf + +
@endif
diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index db749be..92fe163 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -28,11 +28,14 @@ {{ $r['mrr'] }} {{ __('admin.status.'.$r['status']) }} - - - {{ __('admin.impersonate') }} - +
+ @csrf + +
@empty diff --git a/routes/web.php b/routes/web.php index cdf3cda..ba9dce3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,7 +46,8 @@ Route::middleware('auth')->group(function () { Route::get('/support', Support::class)->name('support'); // Return from an admin impersonation session (accessible as the customer user). - Route::get('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave'); + // POST so Laravel's CSRF middleware protects the identity change. + Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave'); }); // Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13). @@ -58,7 +59,8 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); - Route::get('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); + // POST so the identity change is CSRF-protected (a GET could be forced cross-site). + Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); }); diff --git a/tests/Feature/ImpersonationTest.php b/tests/Feature/ImpersonationTest.php index 7423219..3ec8758 100644 --- a/tests/Feature/ImpersonationTest.php +++ b/tests/Feature/ImpersonationTest.php @@ -10,7 +10,7 @@ it('lets an admin impersonate a customer and returns to the admin session', func $customer = Customer::factory()->create(['email' => 'c@imp.test', 'name' => 'Imp Kunde']); $this->actingAs($admin) - ->get(route('admin.impersonate', $customer->uuid)) + ->post(route('admin.impersonate', $customer->uuid)) ->assertRedirect(route('dashboard')); $customer->refresh(); @@ -19,7 +19,7 @@ it('lets an admin impersonate a customer and returns to the admin session', func ->and(session('impersonator_id'))->toBe($admin->id); // Return to the admin session. - $this->get(route('impersonate.leave'))->assertRedirect(route('admin.overview')); + $this->post(route('impersonate.leave'))->assertRedirect(route('admin.overview')); expect(auth()->id())->toBe($admin->id) ->and(session()->has('impersonator_id'))->toBeFalse(); @@ -33,17 +33,25 @@ it('reuses an existing portal user with the same email', function () { expect($customer->fresh()->user_id)->toBe($existing->id); }); +it('refuses to link an admin account as a portal login', function () { + $adminUser = User::factory()->create(['email' => 'ops@imp.test', 'is_admin' => true]); + $customer = Customer::factory()->create(['email' => 'ops@imp.test']); + + expect(fn () => $customer->ensureUser())->toThrow(RuntimeException::class); + expect($customer->fresh()->user_id)->toBeNull(); +}); + it('forbids a non-admin from impersonating', function () { $user = User::factory()->create(['is_admin' => false]); $customer = Customer::factory()->create(); $this->actingAs($user) - ->get(route('admin.impersonate', $customer->uuid)) + ->post(route('admin.impersonate', $customer->uuid)) ->assertForbidden(); }); it('gates impersonation start to authenticated users', function () { $customer = Customer::factory()->create(); - $this->get(route('admin.impersonate', $customer->uuid))->assertRedirect('/login'); + $this->post(route('admin.impersonate', $customer->uuid))->assertRedirect('/login'); });