From 0798f84c2cbd2343100e1e79b51a7a74c155558c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:01 +0200 Subject: [PATCH] fix(auth): protect the root user and owners from deletion by admins An admin of the root team could delete the root user (id 0) and the owners of the root team from the user list. - Never delete the root user here - Refuse a user whose root-team role is higher than the caller's - Refuse the caller's own account; the profile has its own flow --- app/Livewire/Team/AdminView.php | 37 +++++++ tests/Feature/AdminViewUserDeletionTest.php | 102 ++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/Feature/AdminViewUserDeletionTest.php diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index c98e972fd6..85f5c1c683 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -2,6 +2,7 @@ namespace App\Livewire\Team; +use App\Enums\Role; use App\Models\User; use Livewire\Component; use Livewire\WithPagination; @@ -79,6 +80,10 @@ class AdminView extends Component return $this->dispatch('error', 'User not found'); } + if ($error = $this->deletionError(auth()->user(), $user)) { + return $this->dispatch('error', $error); + } + try { $user->delete(); auditLog('ui.user.deleted', [ @@ -96,6 +101,38 @@ class AdminView extends Component } } + /** + * Instance admins may delete other users, but never the root user, their + * own account (use the profile instead), or a user whose role in the root + * team is higher than their own. + */ + private function deletionError(User $actor, User $target): ?string + { + if ($target->id === 0) { + return 'The root user cannot be deleted.'; + } + + if ($target->id === $actor->id) { + return 'Delete your own account from your profile.'; + } + + $actorRole = $this->rootTeamRole($actor); + $targetRole = $this->rootTeamRole($target); + + if (! $actorRole || ($targetRole && $targetRole->gt($actorRole))) { + return 'You cannot delete a user with a higher role in the root team.'; + } + + return null; + } + + private function rootTeamRole(User $user): ?Role + { + $role = $user->teams()->where('teams.id', 0)->first()?->pivot?->role; + + return $role ? Role::tryFrom($role) : null; + } + public function render() { abort_unless(isInstanceAdmin(), 403); diff --git a/tests/Feature/AdminViewUserDeletionTest.php b/tests/Feature/AdminViewUserDeletionTest.php new file mode 100644 index 0000000000..4852a96f34 --- /dev/null +++ b/tests/Feature/AdminViewUserDeletionTest.php @@ -0,0 +1,102 @@ + 0]); + + $this->rootTeam = Team::factory()->create(['id' => 0]); + $this->rootUser = User::factory()->create(['id' => 0]); + + $this->rootAdmin = User::factory()->create(); + $this->rootTeam->members()->attach($this->rootAdmin->id, ['role' => 'admin']); + + $this->rootOwner = User::factory()->create(); + $this->rootTeam->members()->attach($this->rootOwner->id, ['role' => 'owner']); +}); + +function actAsRootTeamMemberForAdminViewDeletion(User $user): void +{ + test()->actingAs($user); + session(['currentTeam' => Team::find(0)]); +} + +test('a root team admin cannot delete the root user', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', 0, 'password') + ->assertDispatched('error', 'The root user cannot be deleted.'); + + expect(User::find(0))->not->toBeNull(); +}); + +test('a root team admin cannot delete a root team owner', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootOwner->id, 'password') + ->assertDispatched('error', 'You cannot delete a user with a higher role in the root team.'); + + expect(User::find($this->rootOwner->id))->not->toBeNull(); +}); + +test('a root team owner cannot delete the root user', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootOwner); + + Livewire::test(AdminView::class) + ->call('delete', 0, 'password') + ->assertDispatched('error', 'The root user cannot be deleted.'); + + expect(User::find(0))->not->toBeNull(); +}); + +test('an instance admin cannot delete their own account from the admin view', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootAdmin->id, 'password') + ->assertDispatched('error', 'Delete your own account from your profile.'); + + expect(User::find($this->rootAdmin->id))->not->toBeNull(); +}); + +test('a root team owner can delete a root team admin and a member', function () { + $member = User::factory()->create(); + $this->rootTeam->members()->attach($member->id, ['role' => 'member']); + + actAsRootTeamMemberForAdminViewDeletion($this->rootOwner); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootAdmin->id, 'password') + ->assertReturned(true) + ->call('delete', $member->id, 'password') + ->assertReturned(true); + + expect(User::find($this->rootAdmin->id))->toBeNull() + ->and(User::find($member->id))->toBeNull(); +}); + +test('a root team admin can delete another root team admin and a user outside the root team', function () { + $otherAdmin = User::factory()->create(); + $this->rootTeam->members()->attach($otherAdmin->id, ['role' => 'admin']); + $outsider = User::factory()->create(); + + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $otherAdmin->id, 'password') + ->assertReturned(true) + ->call('delete', $outsider->id, 'password') + ->assertReturned(true); + + expect(User::find($otherAdmin->id))->toBeNull() + ->and(User::find($outsider->id))->toBeNull(); +});