Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
1 change: 1 addition & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
16 changes: 13 additions & 3 deletions src/app/Http/Controllers/Api/V1/Admin/CdrController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 14 additions & 4 deletions src/app/Http/Controllers/Api/V1/Admin/MapMetricController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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"),
Expand All @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions src/app/Http/Controllers/Api/V1/Admin/MetricController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion src/app/Services/AuthorizationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
}
11 changes: 10 additions & 1 deletion src/app/Services/ReportsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
179 changes: 179 additions & 0 deletions src/tests/Feature/AuthorizationServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

use App\Models\User;
use App\Services\AuthorizationService;
use App\Services\HttpService;
use App\Services\RootServerService;
use Laravel\Sanctum\Sanctum;

beforeAll(function () {
putenv("ENVIRONMENT=test");
});

test('isRootServiceBodyAdmin returns true for top level admin', function () {
$admin = User::factory()->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();
});
Loading
Loading