From 190190547643116f2b4d47e6a65278d656ff0a35 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 18 May 2026 15:45:53 -0400 Subject: [PATCH 1/7] Authorize access to 'all cases' view Add authorization checks for the "All cases" listing. CasesController@index now accepts an optional $type param and aborts with 403 when $type === 'all' and the current user lacks the view-all_cases permission (admins allowed via Gate::before). The API route get_all_cases is also protected with the can:view-all_cases middleware to ensure the underlying endpoint is gated. --- ProcessMaker/Http/Controllers/CasesController.php | 11 ++++++++++- routes/v1_1/api.php | 3 ++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Http/Controllers/CasesController.php b/ProcessMaker/Http/Controllers/CasesController.php index 2dfcb6db82..0889be2a92 100644 --- a/ProcessMaker/Http/Controllers/CasesController.php +++ b/ProcessMaker/Http/Controllers/CasesController.php @@ -22,10 +22,19 @@ 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 cases the user didn't participate in, + // so it must be gated by the same permission as the underlying API + // (`view-all_cases`). Admins are allowed through Gate::before. + 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/routes/v1_1/api.php b/routes/v1_1/api.php index 10547dc52f..abda16e8ee 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -35,7 +35,8 @@ Route::name('cases.')->prefix('cases')->group(function () { // Route to list all cases Route::get('get_all_cases', [CaseController::class, 'getAllCases']) - ->name('all_cases'); + ->name('all_cases') + ->middleware('can:view-all_cases'); // Route to list all in-progress cases Route::get('get_in_progress', [CaseController::class, 'getInProgress']) From 486254071239882cdb5fb73234d846f5cf808179 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 18 May 2026 15:47:57 -0400 Subject: [PATCH 2/7] Add tests for view-all_cases permission Add tests to enforce and validate the 'view-all_cases' permission. API test (Api\V1_1\CaseControllerTest) initializes permissions, creates a non-admin user and sample cases, asserts a 403 response without the permission and 200 with the permission (and checks the returned data count). Web tests (CasesControllerTest) register the Gate/Permission, assert the 'all' cases page returns 403 for users without the permission and 200 for users with it, and confirm other tabs and the default cases page remain accessible. Also add required Gate/Permission imports. --- tests/Feature/Api/V1_1/CaseControllerTest.php | 28 ++++++++++ tests/Feature/CasesControllerTest.php | 56 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/tests/Feature/Api/V1_1/CaseControllerTest.php b/tests/Feature/Api/V1_1/CaseControllerTest.php index e0296d3694..f7bb230a1f 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,33 @@ 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 + { + // Seed permissions and register gates so the `can:view-all_cases` + // middleware on api.1.1.cases.all_cases is enforceable in tests. + $this->initializePermissions(); + + $nonAdmin = User::factory()->create([ + 'is_administrator' => false, + ]); + + // Create some cases so a permitted user would get a non-empty payload. + self::createCasesStartedForUser($nonAdmin->id, 3); + + // Without the permission, access is denied. + $response = $this->actingAs($nonAdmin, 'api') + ->json('GET', route('api.1.1.cases.all_cases')); + $response->assertStatus(403); + + // Granting the permission restores access. + $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_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 From f8d45c33f9db5d398b8cae43a856a11bd643e0b2 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 18 May 2026 17:04:44 -0400 Subject: [PATCH 3/7] Remove permission middleware in api v1.1 --- routes/v1_1/api.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index abda16e8ee..10547dc52f 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -35,8 +35,7 @@ Route::name('cases.')->prefix('cases')->group(function () { // Route to list all cases Route::get('get_all_cases', [CaseController::class, 'getAllCases']) - ->name('all_cases') - ->middleware('can:view-all_cases'); + ->name('all_cases'); // Route to list all in-progress cases Route::get('get_in_progress', [CaseController::class, 'getInProgress']) From 44829fbc766f5ff18570e277a70a82a636960368 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 18 May 2026 17:09:50 -0400 Subject: [PATCH 4/7] Restrict case listing; allow users to view own cases Add authorization in getAllCases to allow users to fetch cases scoped to themselves while requiring the 'view-all_cases' permission for broader queries. The code retrieves the authenticated user and requested userId, allows the request if it's for the user's own cases, and aborts with 403 if the caller lacks the permission (admins continue to be handled via Gate::before). --- .../Http/Controllers/Api/V1_1/CaseController.php | 11 +++++++++++ 1 file changed, 11 insertions(+) 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); From 5f08f50ed34f6ba2fd836126fef2d8164e25dd3e Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 18 May 2026 17:38:02 -0400 Subject: [PATCH 5/7] Add tests for cases access control and Gate setup Ensure the 'view-all_cases' permission row exists and register a Gate in tests so the can:view-all_cases middleware is enforceable. Update the existing forbidden test to define the permission and Gate, and add two new tests: one that confirms a user can view their own cases (scoped by userId) without the global permission, and another that verifies a user cannot view another user's cases without the permission and that granting it restores access. Creates test data via factories and asserts correct response codes and payload counts. --- tests/Feature/Api/V1_1/CaseControllerTest.php | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/tests/Feature/Api/V1_1/CaseControllerTest.php b/tests/Feature/Api/V1_1/CaseControllerTest.php index f7bb230a1f..7b6bc48f8a 100644 --- a/tests/Feature/Api/V1_1/CaseControllerTest.php +++ b/tests/Feature/Api/V1_1/CaseControllerTest.php @@ -479,23 +479,26 @@ public function test_get_my_cases_counters_ok(): void public function test_get_all_cases_forbidden_without_view_all_cases_permission(): void { - // Seed permissions and register gates so the `can:view-all_cases` - // middleware on api.1.1.cases.all_cases is enforceable in tests. - $this->initializePermissions(); + // 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, ]); - // Create some cases so a permitted user would get a non-empty payload. self::createCasesStartedForUser($nonAdmin->id, 3); - // Without the permission, access is denied. + // 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. + // Granting the permission restores access to the unscoped query. $nonAdmin->giveDirectPermission('view-all_cases'); $response = $this->actingAs($nonAdmin, 'api') @@ -504,6 +507,57 @@ public function test_get_all_cases_forbidden_without_view_all_cases_permission() $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'); From fd53631afba463112231b145d82233e262e98de4 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Mon, 18 May 2026 17:48:22 -0400 Subject: [PATCH 6/7] Update comment --- ProcessMaker/Http/Controllers/CasesController.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ProcessMaker/Http/Controllers/CasesController.php b/ProcessMaker/Http/Controllers/CasesController.php index 0889be2a92..cf195ceaa9 100644 --- a/ProcessMaker/Http/Controllers/CasesController.php +++ b/ProcessMaker/Http/Controllers/CasesController.php @@ -28,9 +28,11 @@ class CasesController extends Controller */ public function index($type = null) { - // The "All cases" tab exposes cases the user didn't participate in, - // so it must be gated by the same permission as the underlying API - // (`view-all_cases`). Admins are allowed through Gate::before. + // 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); } From 96a6b3db88adcc736c44ba549f391a6672af23ee Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 19 May 2026 11:23:18 -0400 Subject: [PATCH 7/7] Resolve failing CaseControllerSearchTests --- tests/Feature/Api/V1_1/CaseControllerSearchTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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