From 79c25fbf8332923b7dc2fe93f162ccf3be5a0026 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 23:49:21 +0200 Subject: [PATCH] fix(accounts): make last-user removal atomic (lock + recount) to prevent a zero-user lock-out race Codex P2: concurrent deletes could both pass the >1 check. Now the count + delete run in one transaction under a row lock. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Settings/Users.php | 38 ++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/app/Livewire/Settings/Users.php b/app/Livewire/Settings/Users.php index 9acbf6c..9d0b0a9 100644 --- a/app/Livewire/Settings/Users.php +++ b/app/Livewire/Settings/Users.php @@ -6,6 +6,7 @@ use App\Models\AuditEvent; use App\Models\User; use App\Services\SessionService; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\On; use Livewire\Component; @@ -57,19 +58,36 @@ class Users extends Component #[On('userRemoved')] public function remove(int $userId, SessionService $sessions): void { - $user = User::find($userId); - if (! $user || ! $this->canRemove($user)) { + $self = Auth::id(); + + // Atomic: lock the user rows + recount inside the transaction so two concurrent + // deletes can't both pass the "more than one account" check and strand the + // install with zero users (a lock-out). The last account is never removable. + $email = DB::transaction(function () use ($userId, $self, $sessions): ?string { + if (User::lockForUpdate()->count() <= 1) { + return null; + } + + $user = User::find($userId); + if (! $user || $user->getKey() === $self) { + return null; + } + + $email = $user->email; + + // Kill every session + rotate the remember_token BEFORE deleting, so a + // stolen remember-me cookie can never resurrect the account between the + // session wipe and the row delete. + $sessions->logoutUserEverywhere($user); + $user->delete(); + + return $email; + }); + + if ($email === null) { return; } - $email = $user->email; - - // Kill every session + rotate the remember_token BEFORE deleting, so a - // stolen remember-me cookie can never resurrect the account between the - // session wipe and the row delete. - $sessions->logoutUserEverywhere($user); - $user->delete(); - $this->audit('user.delete', $email); $this->dispatch('notify', message: __('accounts.remove_notify')); }