diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/CaseController.php b/ProcessMaker/Http/Controllers/Api/V1_1/CaseController.php index d76226bb96..29994e5465 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/CaseController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/CaseController.php @@ -43,6 +43,17 @@ public function __construct(private Request $request, CaseApiRepository $caseRep */ public function getAllCases(CaseListRequest $request): JsonResponse { + // Users are always allowed to view cases scoped to themselves + // Any broader query requires the `view-all_cases` permission. + // Admins pass through via Gate::before in AuthServiceProvider. + $authUser = Auth::user(); + $requestedUserId = $request->filled('userId') ? (int) $request->input('userId') : null; + $isViewingOwnCases = $requestedUserId !== null && $requestedUserId === $authUser->id; + + if (!$isViewingOwnCases && !$authUser->can('view-all_cases')) { + abort(403); + } + $query = $this->caseRepository->getAllCases($request); return $this->paginateResponse($query); diff --git a/ProcessMaker/Http/Controllers/CasesController.php b/ProcessMaker/Http/Controllers/CasesController.php index 2dfcb6db82..cf195ceaa9 100644 --- a/ProcessMaker/Http/Controllers/CasesController.php +++ b/ProcessMaker/Http/Controllers/CasesController.php @@ -22,10 +22,21 @@ class CasesController extends Controller /** * Get the list of requests. * + * @param string|null $type One of `all|in_progress|completed` (constrained by the route). + * * @return \Illuminate\View\View|\Illuminate\Contracts\View */ - public function index() + public function index($type = null) { + // The "All cases" tab exposes every case in the platform regardless + // of the user's relationship to it, so it is gated by the + // `view-all_cases` permission. The other tabs (My cases, In progress, + // Completed) are scoped to the user and need no gate. + // Admins bypass this check via Gate::before in AuthServiceProvider. + if ($type === 'all' && !Auth::user()->can('view-all_cases')) { + abort(403); + } + $manager = app(ScreenBuilderManager::class); event(new ScreenBuilderStarting($manager, 'FORM')); $currentUser = Auth::user()->only(['id', 'username', 'fullname', 'firstname', 'lastname', 'avatar']); diff --git a/tests/Feature/Api/V1_1/CaseControllerSearchTest.php b/tests/Feature/Api/V1_1/CaseControllerSearchTest.php index d0f8638a67..9a6a928857 100644 --- a/tests/Feature/Api/V1_1/CaseControllerSearchTest.php +++ b/tests/Feature/Api/V1_1/CaseControllerSearchTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Api\V1_1; +use Illuminate\Support\Facades\Gate; +use ProcessMaker\Models\Permission; use ProcessMaker\Models\User; use ProcessMaker\Repositories\CaseUtils; use Tests\Feature\Shared\RequestHelper; @@ -16,6 +18,17 @@ public function setUp(): void parent::setUp(); $this->user = CaseControllerTest::createUser('user_a'); + + // These tests intentionally exercise the unscoped `get_all_cases` + // endpoint, which now requires `view-all_cases`. Grant the + // non-admin test user the permission so each `apiCall` reaches + // the search logic rather than being short-circuited with a 403. + Permission::firstOrCreate( + ['name' => 'view-all_cases'], + ['title' => 'View All Cases'], + ); + Gate::define('view-all_cases', fn ($user) => $user->hasPermission('view-all_cases')); + $this->user->giveDirectPermission('view-all_cases'); } public function tearDown(): void diff --git a/tests/Feature/Api/V1_1/CaseControllerTest.php b/tests/Feature/Api/V1_1/CaseControllerTest.php index e0296d3694..7b6bc48f8a 100644 --- a/tests/Feature/Api/V1_1/CaseControllerTest.php +++ b/tests/Feature/Api/V1_1/CaseControllerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Api\V1_1; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use ProcessMaker\Constants\CaseStatusConstants; use ProcessMaker\Models\CaseParticipated; @@ -476,6 +477,87 @@ public function test_get_my_cases_counters_ok(): void $response->assertJsonFragment(['totalMyRequest' => 5]); } + public function test_get_all_cases_forbidden_without_view_all_cases_permission(): void + { + // Ensure the permission row exists and register it as a Laravel Gate + // so $user->can('view-all_cases') is enforceable in tests. + Permission::firstOrCreate( + ['name' => 'view-all_cases'], + ['title' => 'View All Cases'], + ); + Gate::define('view-all_cases', fn ($user) => $user->hasPermission('view-all_cases')); + + $nonAdmin = User::factory()->create([ + 'is_administrator' => false, + ]); + + self::createCasesStartedForUser($nonAdmin->id, 3); + + // Unscoped request (the "All cases" tab) — denied without the permission. + $response = $this->actingAs($nonAdmin, 'api') + ->json('GET', route('api.1.1.cases.all_cases')); + $response->assertStatus(403); + + // Granting the permission restores access to the unscoped query. + $nonAdmin->giveDirectPermission('view-all_cases'); + + $response = $this->actingAs($nonAdmin, 'api') + ->json('GET', route('api.1.1.cases.all_cases')); + $response->assertStatus(200); + $response->assertJsonCount(3, 'data'); + } + + public function test_get_all_cases_allows_user_to_view_their_own_cases_without_permission(): void + { + // The endpoint is shared by the "My cases" tab, which scopes the + // query to the authenticated user. That self-scoped path must work + // even when the user lacks `view-all_cases`. + $nonAdmin = User::factory()->create([ + 'is_administrator' => false, + ]); + + $ownCases = self::createCasesStartedForUser($nonAdmin->id, 4); + $otherUser = self::createUser('other_user'); + self::createCasesStartedForUser($otherUser->id, 6); + + $response = $this->actingAs($nonAdmin, 'api') + ->json('GET', route('api.1.1.cases.all_cases', ['userId' => $nonAdmin->id])); + + $response->assertStatus(200); + $response->assertJsonCount($ownCases->count(), 'data'); + $response->assertJsonMissing(['user_id' => $otherUser->id]); + } + + public function test_get_all_cases_forbids_user_from_viewing_another_users_cases_without_permission(): void + { + Permission::firstOrCreate( + ['name' => 'view-all_cases'], + ['title' => 'View All Cases'], + ); + Gate::define('view-all_cases', fn ($user) => $user->hasPermission('view-all_cases')); + + $nonAdmin = User::factory()->create([ + 'is_administrator' => false, + ]); + $otherUser = self::createUser('other_user'); + self::createCasesStartedForUser($otherUser->id, 2); + + // Passing another user's id must require `view-all_cases`; otherwise + // any authenticated user could iterate userIds to enumerate the + // entire platform. + $response = $this->actingAs($nonAdmin, 'api') + ->json('GET', route('api.1.1.cases.all_cases', ['userId' => $otherUser->id])); + $response->assertStatus(403); + + // With the permission, the same request succeeds. + $nonAdmin->giveDirectPermission('view-all_cases'); + + $response = $this->actingAs($nonAdmin, 'api') + ->json('GET', route('api.1.1.cases.all_cases', ['userId' => $otherUser->id])); + $response->assertStatus(200); + $response->assertJsonCount(2, 'data'); + } + public function test_get_all_cases_participants(): void { $userA = $this->createUser('user_a'); diff --git a/tests/Feature/CasesControllerTest.php b/tests/Feature/CasesControllerTest.php index cb5a2f116a..23f182665f 100644 --- a/tests/Feature/CasesControllerTest.php +++ b/tests/Feature/CasesControllerTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature; use Carbon\Carbon; +use Illuminate\Support\Facades\Gate; use ProcessMaker\Http\Controllers\CasesController; +use ProcessMaker\Models\Permission; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; @@ -60,6 +62,60 @@ public function testShowCaseWithUserWithoutParticipation() $response->assertStatus(403); } + public function testCasesAllPageReturns403WithoutViewAllCasesPermission() + { + Permission::firstOrCreate( + ['name' => 'view-all_cases'], + ['title' => 'View All Cases'], + ); + Gate::define('view-all_cases', fn ($user) => $user->hasPermission('view-all_cases')); + + $user = User::factory()->create(['is_administrator' => false]); + $this->actingAs($user); + + $response = $this->get(route('cases-main.index', ['type' => 'all'])); + + $response->assertStatus(403); + // Confirms the standard ProcessMaker "Not Authorized" page renders + // rather than the cases shell with an empty list. + $response->assertSee('Not Authorized'); + } + + public function testCasesAllPageReturns200WithViewAllCasesPermission() + { + Permission::firstOrCreate( + ['name' => 'view-all_cases'], + ['title' => 'View All Cases'], + ); + Gate::define('view-all_cases', fn ($user) => $user->hasPermission('view-all_cases')); + + $user = User::factory()->create(['is_administrator' => false]); + $user->giveDirectPermission('view-all_cases'); + $this->actingAs($user); + + $response = $this->get(route('cases-main.index', ['type' => 'all'])); + + $response->assertStatus(200); + $response->assertViewIs('cases.casesMain'); + } + + public function testCasesOtherTabsRemainAccessibleWithoutViewAllCasesPermission() + { + $user = User::factory()->create(['is_administrator' => false]); + $this->actingAs($user); + + foreach (['in_progress', 'completed'] as $type) { + $response = $this->get(route('cases-main.index', ['type' => $type])); + $response->assertStatus(200); + $response->assertViewIs('cases.casesMain'); + } + + // Default `/cases` landing should also work for everyone. + $response = $this->get(route('cases-main.index')); + $response->assertStatus(200); + $response->assertViewIs('cases.casesMain'); + } + public function testShowCaseWithUserAdmin() { // Create user admin