Skip to content

Commit 69e6d80

Browse files
authored
Merge pull request #19327 from grokability/fix-bulk-restore-authz
Bulk Asset Restore: Fixed FD-56301 - tighter guards on bulk restoring
2 parents 284cf8f + 6863290 commit 69e6d80

2 files changed

Lines changed: 103 additions & 6 deletions

File tree

app/Http/Controllers/Assets/BulkAssetsController.php

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -925,19 +925,25 @@ public function storeCheckin(Request $request): RedirectResponse
925925

926926
public function restore(Request $request): RedirectResponse
927927
{
928-
$this->authorize('update', Asset::class);
928+
// Restore is a delete-level action across the codebase. The bulk
929+
// POST handler used to gate on authorize('update', Asset::class),
930+
// letting an assets.edit user undo an admin's soft-delete.
931+
$this->authorize('delete', Asset::class);
929932
$assetIds = $request->input('ids');
930933

931934
if (empty($assetIds)) {
932935
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.restore.nothing_updated'));
933-
} else {
934-
foreach ($assetIds as $key => $assetId) {
935-
$asset = Asset::withTrashed()->find($assetId);
936+
}
937+
938+
foreach ($assetIds as $assetId) {
939+
// Skip invalid or forged IDs. Prior code called ->restore() on
940+
// null and 500'd on the first bad id in the payload.
941+
if ($asset = Asset::withTrashed()->find($assetId)) {
936942
$asset->restore();
937943
}
938-
939-
return redirect()->route('hardware.index')->with('success', trans('admin/hardware/message.restore.success'));
940944
}
945+
946+
return redirect()->route('hardware.index')->with('success', trans('admin/hardware/message.restore.success'));
941947
}
942948

943949
public function hasUndeployableStatus(array $asset_ids)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
namespace Tests\Feature\Assets\Ui;
4+
5+
use App\Models\Asset;
6+
use App\Models\User;
7+
use Tests\TestCase;
8+
9+
/**
10+
* Regression tests for a broken-access-control bug where
11+
* BulkAssetsController::restore() (POST /hardware/bulkrestore) used
12+
* $this->authorize('update', Asset::class), letting any user with
13+
* assets.edit undo an admin's soft-delete by sending the ID directly.
14+
* Every other restore code path in the codebase gated on
15+
* authorize('delete', ...):
16+
*
17+
* - AssetsController::getRestore (single-asset web)
18+
* - Api\AssetsController::restore (single-asset API)
19+
* - BulkAssetsController::edit() 'restore' preview branch
20+
*
21+
* The fix aligns the bulk POST handler with the rest by gating on the
22+
* `delete` ability at both the class level and per-instance level.
23+
*/
24+
class BulkRestoreAuthorizationTest extends TestCase
25+
{
26+
public function test_user_with_edit_but_no_delete_cannot_bulk_restore()
27+
{
28+
$asset = Asset::factory()->deleted()->create();
29+
30+
$user = User::factory()->viewAssets()->editAssets()->create();
31+
32+
$this->actingAs($user)
33+
->post(route('hardware/bulkrestore'), [
34+
'ids' => [$asset->id],
35+
])
36+
->assertForbidden();
37+
38+
$asset->refresh();
39+
$this->assertNotNull($asset->deleted_at, 'Asset must remain soft-deleted after a forbidden restore attempt.');
40+
}
41+
42+
public function test_user_with_only_view_cannot_bulk_restore()
43+
{
44+
$asset = Asset::factory()->deleted()->create();
45+
46+
$user = User::factory()->viewAssets()->create();
47+
48+
$this->actingAs($user)
49+
->post(route('hardware/bulkrestore'), [
50+
'ids' => [$asset->id],
51+
])
52+
->assertForbidden();
53+
54+
$asset->refresh();
55+
$this->assertNotNull($asset->deleted_at);
56+
}
57+
58+
public function test_user_with_delete_permission_can_bulk_restore()
59+
{
60+
$asset = Asset::factory()->deleted()->create();
61+
62+
$user = User::factory()->viewAssets()->deleteAssets()->create();
63+
64+
$this->actingAs($user)
65+
->post(route('hardware/bulkrestore'), [
66+
'ids' => [$asset->id],
67+
])
68+
->assertRedirect(route('hardware.index'));
69+
70+
$asset->refresh();
71+
$this->assertNull($asset->deleted_at, 'Asset should be restored (deleted_at cleared) after an authorized bulk restore.');
72+
}
73+
74+
public function test_bulk_restore_skips_nonexistent_ids_instead_of_500()
75+
{
76+
// Prior code did Asset::withTrashed()->find($id)->restore() with no
77+
// null guard, so any invalid or forged ID crashed the whole request.
78+
$realAsset = Asset::factory()->deleted()->create();
79+
80+
$user = User::factory()->viewAssets()->deleteAssets()->create();
81+
82+
$this->actingAs($user)
83+
->post(route('hardware/bulkrestore'), [
84+
'ids' => [$realAsset->id, 99999999],
85+
])
86+
->assertRedirect(route('hardware.index'));
87+
88+
$realAsset->refresh();
89+
$this->assertNull($realAsset->deleted_at);
90+
}
91+
}

0 commit comments

Comments
 (0)