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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 23:49:21 +02:00
parent 3399a5017a
commit 79c25fbf83
1 changed files with 28 additions and 10 deletions

View File

@ -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,9 +58,19 @@ class Users extends Component
#[On('userRemoved')]
public function remove(int $userId, SessionService $sessions): void
{
$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 || ! $this->canRemove($user)) {
return;
if (! $user || $user->getKey() === $self) {
return null;
}
$email = $user->email;
@ -70,6 +81,13 @@ class Users extends Component
$sessions->logoutUserEverywhere($user);
$user->delete();
return $email;
});
if ($email === null) {
return;
}
$this->audit('user.delete', $email);
$this->dispatch('notify', message: __('accounts.remove_notify'));
}