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') }}
-
+
@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') }}
-
+
|
@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');
});