diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9c74510bd..00a26d9c0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -30,7 +30,8 @@ "Bash(gh run list:*)", "Bash(gh run view:*)", "Bash(xargs kill:*)", - "Bash(npm ls:*)" + "Bash(npm ls:*)", + "Bash(gh issue view:*)" ], "deny": [], "ask": [] diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4397bf53f..474589818 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -6,6 +6,7 @@ * Dialbacks are now included in the call detail reports [#1409] * HTML formatted notifications for voicemail. * Added enabled field to volunteer CSV and JSON exports, with download buttons on Volunteers page [#1411] +* Root service body admins can now access meeting request logs that have no service_body_id assigned [#1399] ### 4.5.0 (July 11, 2025) * Added new feature that allow for creating a custom prompt for language selection feature. [#1228] diff --git a/src/app/Http/Controllers/Api/V1/Admin/CdrController.php b/src/app/Http/Controllers/Api/V1/Admin/CdrController.php index a47bd96bb..857529d3b 100644 --- a/src/app/Http/Controllers/Api/V1/Admin/CdrController.php +++ b/src/app/Http/Controllers/Api/V1/Admin/CdrController.php @@ -3,17 +3,20 @@ namespace App\Http\Controllers\Api\V1\Admin; use App\Http\Controllers\Controller; +use App\Services\AuthorizationService; use App\Services\ReportsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class CdrController extends Controller { - private $reportsService; + private ReportsService $reportsService; + private AuthorizationService $authorizationService; - public function __construct(ReportsService $reportsService) + public function __construct(ReportsService $reportsService, AuthorizationService $authorizationService) { $this->reportsService = $reportsService; + $this->authorizationService = $authorizationService; } /** @@ -73,8 +76,15 @@ public function __construct(ReportsService $reportsService) */ public function index(Request $request): JsonResponse { + $serviceBodyId = $request->get('service_body_id'); + + // Validate access to service_body_id=0 (unattached records) + if (intval($serviceBodyId) == 0 && !$this->authorizationService->isRootServiceBodyAdmin()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + return response()->json($this->reportsService->getCallDetailRecords( - $request->get('service_body_id'), + $serviceBodyId, $request->get('date_range_start'), $request->get('date_range_end'), filter_var($request->get('recurse'), FILTER_VALIDATE_BOOLEAN) diff --git a/src/app/Http/Controllers/Api/V1/Admin/MapMetricController.php b/src/app/Http/Controllers/Api/V1/Admin/MapMetricController.php index 9ad191128..151146309 100644 --- a/src/app/Http/Controllers/Api/V1/Admin/MapMetricController.php +++ b/src/app/Http/Controllers/Api/V1/Admin/MapMetricController.php @@ -4,16 +4,19 @@ use App\Constants\EventId; use App\Http\Controllers\Controller; +use App\Services\AuthorizationService; use App\Services\ReportsService; use Illuminate\Http\Request; class MapMetricController extends Controller { - private $reportsService; + private ReportsService $reportsService; + private AuthorizationService $authorizationService; - public function __construct(ReportsService $reportsService) + public function __construct(ReportsService $reportsService, AuthorizationService $authorizationService) { $this->reportsService = $reportsService; + $this->authorizationService = $authorizationService; } /** @@ -86,10 +89,17 @@ public function __construct(ReportsService $reportsService) */ public function index(Request $request) { + $serviceBodyId = $request->get("service_body_id"); + + // Validate access to service_body_id=0 (unattached records) + if (intval($serviceBodyId) == 0 && !$this->authorizationService->isRootServiceBodyAdmin()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + if ($request->get('format') == "csv") { $eventId = $request->get("event_id"); $data = $this->reportsService->getMapMetricsCsv( - $request->get("service_body_id"), + $serviceBodyId, $eventId, $request->get("date_range_start"), $request->get("date_range_end"), @@ -105,7 +115,7 @@ public function index(Request $request) )); } else { $data = $this->reportsService->getMapMetrics( - $request->get("service_body_id"), + $serviceBodyId, $request->get("date_range_start"), $request->get("date_range_end"), filter_var($request->get("recurse"), FILTER_VALIDATE_BOOLEAN) diff --git a/src/app/Http/Controllers/Api/V1/Admin/MetricController.php b/src/app/Http/Controllers/Api/V1/Admin/MetricController.php index 3a28da990..70773787d 100644 --- a/src/app/Http/Controllers/Api/V1/Admin/MetricController.php +++ b/src/app/Http/Controllers/Api/V1/Admin/MetricController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin; use App\Http\Controllers\Controller; +use App\Services\AuthorizationService; use App\Services\ReportsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -16,10 +17,12 @@ class MetricController extends Controller { private ReportsService $reportsService; + private AuthorizationService $authorizationService; - public function __construct(ReportsService $reportsService) + public function __construct(ReportsService $reportsService, AuthorizationService $authorizationService) { $this->reportsService = $reportsService; + $this->authorizationService = $authorizationService; } /** @@ -72,8 +75,15 @@ public function __construct(ReportsService $reportsService) */ public function index(Request $request): JsonResponse { + $serviceBodyId = $request->get("service_body_id"); + + // Validate access to service_body_id=0 (unattached records) + if (intval($serviceBodyId) == 0 && !$this->authorizationService->isRootServiceBodyAdmin()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + $data = $this->reportsService->getMetrics( - $request->get("service_body_id"), + $serviceBodyId, $request->get("date_range_start"), $request->get("date_range_end"), filter_var($request->get("recurse"), FILTER_VALIDATE_BOOLEAN) diff --git a/src/app/Services/AuthorizationService.php b/src/app/Services/AuthorizationService.php index ad2fcce77..aebebf118 100644 --- a/src/app/Services/AuthorizationService.php +++ b/src/app/Services/AuthorizationService.php @@ -8,10 +8,12 @@ class AuthorizationService { private mixed $serviceBodyRights; + private RootServerService $rootServerService; - public function __construct() + public function __construct(RootServerService $rootServerService) { $this->serviceBodyRights = session()->get("auth_service_bodies_rights") ?? null; + $this->rootServerService = $rootServerService; } public function getServiceBodyRights() @@ -61,4 +63,25 @@ public function isTopLevelAdmin(): bool $user = auth()->user(); return $user && $user->is_admin; } + + public function isRootServiceBodyAdmin(): bool + { + if ($this->isTopLevelAdmin()) { + return true; + } + + $serviceBodyRights = $this->getServiceBodyRights(); + if (!$serviceBodyRights || empty($serviceBodyRights)) { + return false; + } + + // Check if any of user's service bodies is a root (no parent) + foreach ($serviceBodyRights as $serviceBodyId) { + $serviceBody = $this->rootServerService->getServiceBody($serviceBodyId); + if ($serviceBody && ($serviceBody->parent_id === null || $serviceBody->parent_id === 0 || $serviceBody->parent_id === "0")) { + return true; + } + } + return false; + } } diff --git a/src/app/Services/ReportsService.php b/src/app/Services/ReportsService.php index 139da104a..e38b57b00 100644 --- a/src/app/Services/ReportsService.php +++ b/src/app/Services/ReportsService.php @@ -11,20 +11,29 @@ class ReportsService extends Service { private RootServerService $rootServerService; private ReportsRepository $reportsRepository; + private AuthorizationService $authorizationService; public function __construct( RootServerService $rootServerService, ReportsRepository $reportsRepository, + AuthorizationService $authorizationService, ) { parent::__construct(App::make(SettingsService::class)); $this->rootServerService = $rootServerService; $this->reportsRepository = $reportsRepository; + $this->authorizationService = $authorizationService; } private function getServiceBodies($serviceBodyId, $recurse): array { if (intval($serviceBodyId) == 0) { - return array_column($this->rootServerService->getServiceBodiesForUser(true), "id"); + $bodies = array_column($this->rootServerService->getServiceBodiesForUser(true), "id"); + + // Root service body admins can see unattached records (service_body_id = 0/NULL) + if ($this->authorizationService->isRootServiceBodyAdmin()) { + $bodies[] = 0; + } + return $bodies; } elseif ($recurse) { return $this->rootServerService->getServiceBodiesForUserRecursively($serviceBodyId); } else { diff --git a/src/tests/Feature/AuthorizationServiceTest.php b/src/tests/Feature/AuthorizationServiceTest.php new file mode 100644 index 000000000..6e0fe8ee9 --- /dev/null +++ b/src/tests/Feature/AuthorizationServiceTest.php @@ -0,0 +1,179 @@ +admin()->create(); + Sanctum::actingAs($admin); + session()->put('auth_is_admin', true); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeTrue(); +}); + +test('isRootServiceBodyAdmin returns true for user with root service body', function () { + // Root service body (parent_id = null) + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => null, + "name" => "Root Service Body", + ]; + + $rootServerService = mock(RootServerService::class, [app(HttpService::class)])->makePartial(); + $rootServerService->shouldReceive("getServiceBody") + ->with("1000") + ->andReturn($rootServiceBody); + + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies_rights', ["1000"]); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeTrue(); +}); + +test('isRootServiceBodyAdmin returns true for user with root service body (parent_id = 0)', function () { + // Root service body (parent_id = 0) + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => 0, + "name" => "Root Service Body", + ]; + + $rootServerService = mock(RootServerService::class, [app(HttpService::class)])->makePartial(); + $rootServerService->shouldReceive("getServiceBody") + ->with("1000") + ->andReturn($rootServiceBody); + + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies_rights', ["1000"]); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeTrue(); +}); + +test('isRootServiceBodyAdmin returns true for user with root service body (parent_id = "0")', function () { + // Root service body (parent_id = "0" as string) + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => "0", + "name" => "Root Service Body", + ]; + + $rootServerService = mock(RootServerService::class, [app(HttpService::class)])->makePartial(); + $rootServerService->shouldReceive("getServiceBody") + ->with("1000") + ->andReturn($rootServiceBody); + + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies_rights', ["1000"]); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeTrue(); +}); + +test('isRootServiceBodyAdmin returns false for user with only child service body', function () { + // Child service body (has a parent) + $childServiceBody = (object)[ + "id" => "1001", + "parent_id" => "1000", + "name" => "Child Service Body", + ]; + + $rootServerService = mock(RootServerService::class, [app(HttpService::class)])->makePartial(); + $rootServerService->shouldReceive("getServiceBody") + ->with("1001") + ->andReturn($childServiceBody); + + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies_rights', ["1001"]); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeFalse(); +}); + +test('isRootServiceBodyAdmin returns false for user with no service bodies', function () { + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies_rights', []); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeFalse(); +}); + +test('isRootServiceBodyAdmin returns false when service body rights is null', function () { + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_is_admin', false); + session()->forget('auth_service_bodies_rights'); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeFalse(); +}); + +test('isRootServiceBodyAdmin returns true when user has mixed service bodies including root', function () { + // Root service body + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => null, + "name" => "Root Service Body", + ]; + + // Child service body + $childServiceBody = (object)[ + "id" => "1001", + "parent_id" => "1000", + "name" => "Child Service Body", + ]; + + $rootServerService = mock(RootServerService::class, [app(HttpService::class)])->makePartial(); + $rootServerService->shouldReceive("getServiceBody") + ->with("1001") + ->andReturn($childServiceBody); + $rootServerService->shouldReceive("getServiceBody") + ->with("1000") + ->andReturn($rootServiceBody); + + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_is_admin', false); + // User has access to both a child service body and a root service body + session()->put('auth_service_bodies_rights', ["1001", "1000"]); + + $authorizationService = app(AuthorizationService::class); + + expect($authorizationService->isRootServiceBodyAdmin())->toBeTrue(); +}); diff --git a/src/tests/Feature/RootServiceBodyAdminReportsTest.php b/src/tests/Feature/RootServiceBodyAdminReportsTest.php new file mode 100644 index 000000000..82e8ad4f9 --- /dev/null +++ b/src/tests/Feature/RootServiceBodyAdminReportsTest.php @@ -0,0 +1,321 @@ +makePartial(); + + $rootServerService->shouldReceive("getServiceBodies") + ->withNoArgs() + ->andReturn($serviceBodies); + + $rootServerService->shouldReceive("getServiceBodiesForUser") + ->withAnyArgs() + ->andReturnUsing(function ($includeGeneral) use ($serviceBodies) { + $result = $serviceBodies; + if ($includeGeneral) { + $result[] = (object)["id" => "0", "name" => "All"]; + } + return $result; + }); + + $rootServerService->shouldReceive("getServiceBodiesForUserRecursively") + ->withAnyArgs() + ->andReturnUsing(function ($serviceBodyId) use ($serviceBodies) { + $ids = [intval($serviceBodyId)]; + foreach ($serviceBodies as $sb) { + if (intval($sb->parent_id) === intval($serviceBodyId)) { + $ids[] = intval($sb->id); + } + } + return $ids; + }); + + foreach ($serviceBodies as $sb) { + $rootServerService->shouldReceive("getServiceBody") + ->with($sb->id) + ->andReturn($sb); + } + + return $rootServerService; +} + +test('root service body admin can access metrics for service_body_id=0', function () { + // Root service body (parent_id = null) + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => null, + "name" => "Root Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$rootServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1000"]); + session()->put('auth_service_bodies_rights', ["1000"]); + + $response = $this->call('GET', '/api/v1/reports/metrics', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(200); +}); + +test('non-root service body admin cannot access metrics for service_body_id=0', function () { + // Child service body (has parent) + $childServiceBody = (object)[ + "id" => "1001", + "parent_id" => "1000", + "name" => "Child Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$childServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1001"]); + session()->put('auth_service_bodies_rights', ["1001"]); + + $response = $this->call('GET', '/api/v1/reports/metrics', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(403); + $response->assertJson(['error' => 'Unauthorized']); +}); + +test('yap server admin can access metrics for service_body_id=0', function () { + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => null, + "name" => "Root Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$rootServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + $admin = User::factory()->admin()->create(); + Sanctum::actingAs($admin); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', true); + + $response = $this->call('GET', '/api/v1/reports/metrics', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(200); +}); + +test('root service body admin can access CDR for service_body_id=0', function () { + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => null, + "name" => "Root Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$rootServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1000"]); + session()->put('auth_service_bodies_rights', ["1000"]); + + $response = $this->call('GET', '/api/v1/reports/cdr', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(200); +}); + +test('non-root service body admin cannot access CDR for service_body_id=0', function () { + $childServiceBody = (object)[ + "id" => "1001", + "parent_id" => "1000", + "name" => "Child Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$childServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1001"]); + session()->put('auth_service_bodies_rights', ["1001"]); + + $response = $this->call('GET', '/api/v1/reports/cdr', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(403); + $response->assertJson(['error' => 'Unauthorized']); +}); + +test('root service body admin can access map metrics for service_body_id=0', function () { + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => null, + "name" => "Root Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$rootServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1000"]); + session()->put('auth_service_bodies_rights', ["1000"]); + + $response = $this->call('GET', '/api/v1/reports/mapmetrics', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(200); +}); + +test('non-root service body admin cannot access map metrics for service_body_id=0', function () { + $childServiceBody = (object)[ + "id" => "1001", + "parent_id" => "1000", + "name" => "Child Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$childServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1001"]); + session()->put('auth_service_bodies_rights', ["1001"]); + + $response = $this->call('GET', '/api/v1/reports/mapmetrics', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(403); + $response->assertJson(['error' => 'Unauthorized']); +}); + +test('root service body admin sees meeting request logs with null service_body_id', function () { + $rootServiceBody = (object)[ + "id" => "1000", + "parent_id" => null, + "name" => "Root Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$rootServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + // Create a meeting search event with service_body_id = 0 (NULL in DB) + RecordEvent::generate( + "meeting_search_123", + EventId::MEETING_SEARCH, + "2023-01-03 12:00:00", + 0, // NULL service_body_id converted to 0 + "", + RecordType::PHONE + ); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1000"]); + session()->put('auth_service_bodies_rights', ["1000"]); + + $response = $this->call('GET', '/api/v1/reports/metrics', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(200); + $data = $response->json(); + // Verify that the meeting search event is included in the summary + $foundMeetingSearch = false; + foreach ($data['summary'] as $summary) { + if ($summary['event_id'] == EventId::MEETING_SEARCH && $summary['counts'] > 0) { + $foundMeetingSearch = true; + break; + } + } + expect($foundMeetingSearch)->toBeTrue(); +}); + +test('non-root service body admin does not see meeting request logs with null service_body_id', function () { + $childServiceBody = (object)[ + "id" => "1001", + "parent_id" => "1000", + "name" => "Child Service Body", + ]; + + $rootServerService = createRootServerServiceMock([$childServiceBody]); + app()->instance(RootServerService::class, $rootServerService); + + // Create a meeting search event with service_body_id = 0 (NULL in DB) + RecordEvent::generate( + "meeting_search_456", + EventId::MEETING_SEARCH, + "2023-01-03 12:00:00", + 0, // NULL service_body_id converted to 0 + "", + RecordType::PHONE + ); + + $user = User::factory()->create(['is_admin' => false]); + Sanctum::actingAs($user); + session()->put('auth_mechanism', AuthMechanism::V2); + session()->put('auth_is_admin', false); + session()->put('auth_service_bodies', ["1001"]); + session()->put('auth_service_bodies_rights', ["1001"]); + + // Request with service_body_id=0 should fail with 403 + $response = $this->call('GET', '/api/v1/reports/metrics', [ + "service_body_id" => 0, + "date_range_start" => "2023-01-01", + "date_range_end" => "2023-01-07", + ]); + + $response->assertStatus(403); +});