fix(admin): harden impersonation + datacenter code per Codex review
- normalize datacenter code before uniqueness validation - ensureUser: race-safe create + refuse linking admin accounts - impersonate start/leave are POST (CSRF-protected) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
879697c6ea
commit
4e86e135a6
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@
|
|||
{{-- Admin impersonation banner — visible on every portal page until the admin returns. --}}
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm font-medium text-ink">
|
||||
<span>{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
|
||||
<a href="{{ route('impersonate.leave') }}" class="inline-flex items-center gap-1 rounded-pill border border-warning-border px-3 py-0.5 font-semibold text-warning underline-offset-2 hover:underline">
|
||||
<x-ui.icon name="log-out" class="size-4" />
|
||||
{{ __('impersonate.leave') }}
|
||||
</a>
|
||||
<form method="POST" action="{{ route('impersonate.leave') }}" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center gap-1 rounded-pill border border-warning-border px-3 py-0.5 font-semibold text-warning underline-offset-2 hover:underline">
|
||||
<x-ui.icon name="log-out" class="size-4" />
|
||||
{{ __('impersonate.leave') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -28,11 +28,14 @@
|
|||
<td class="px-4 py-3 font-mono text-body">{{ $r['mrr'] }}</td>
|
||||
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('admin.impersonate', $r['uuid']) }}"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="log-out" class="size-3.5" />
|
||||
{{ __('admin.impersonate') }}
|
||||
</a>
|
||||
<form method="POST" action="{{ route('admin.impersonate', $r['uuid']) }}" class="inline">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="log-out" class="size-3.5" />
|
||||
{{ __('admin.impersonate') }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue