diff --git a/services/nginx/app/routes/bookingsRoute.php b/services/nginx/app/routes/bookingsRoute.php index d4ef953c..d036b0d3 100644 --- a/services/nginx/app/routes/bookingsRoute.php +++ b/services/nginx/app/routes/bookingsRoute.php @@ -49,7 +49,7 @@ class bookingsRoute } // Check if the user has access to the booking if (!$user->hasAccessToBooking((int)self::getParameter('id'))) { - $response->error('You are not allowed to access this booking', 403); + $response->forbidden(['list_bookings']); } // Return the booking $response->success( @@ -158,7 +158,7 @@ class bookingsRoute } // Check if the user has access to the booking if (!$user->hasAccessToBooking((int)$this->getParameter('id'))) { - $response->error('You are not allowed to update this booking', 403); + $response->forbidden(['list_bookings']); } // Check if the optional parameters are set if (self::isParametersSet(['reference_number'])) { @@ -530,4 +530,4 @@ class bookingsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/cronRoute.php b/services/nginx/app/routes/cronRoute.php index b0417ed0..4862d554 100644 --- a/services/nginx/app/routes/cronRoute.php +++ b/services/nginx/app/routes/cronRoute.php @@ -16,9 +16,7 @@ class cronRoute // Get the post data global $response; // Make sure the user has the SUPERUSER_RUN_CRON permission - if (!$this->requirePermission('SUPERUSER_RUN_CRON')) { - $response->error('Permission denied', 403); - } + $this->requirePermission('SUPERUSER_RUN_CRON'); // Get the user object $user = (new authentication())->get_user(); // Get the post data @@ -49,4 +47,4 @@ class cronRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/departmentGoalsRoute.php b/services/nginx/app/routes/departmentGoalsRoute.php index ba76c685..76507892 100644 --- a/services/nginx/app/routes/departmentGoalsRoute.php +++ b/services/nginx/app/routes/departmentGoalsRoute.php @@ -54,7 +54,11 @@ class departmentGoalsRoute } // Access control: must have all departments unless superuser if (!$isSuperuser && !$hasAllDepartments((array)$goal->departments->value())) { - $response->error('You are not allowed to access this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', (array)$goal->departments->value()), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } $response->success($goal->asArray()); } @@ -115,7 +119,10 @@ class departmentGoalsRoute $goalDepartments = array_map(fn($d) => (int)$d->id, $departments); $missing = array_diff($goalDepartments, array_map('intval', $userDepartments)); if (count($missing) > 0) { - $response->error('You are not allowed to create goals for one or more selected departments', 403); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . (int)$departmentId, $missing), + 'superuser' + ]); } } @@ -159,7 +166,11 @@ class departmentGoalsRoute // If not superuser or creator, require access to existing goal departments if (!$isSuperuser && !$isCreator && !$hasAllDepartments((array)$goal->departments->value(), $userDepartments)) { - $response->error('You are not allowed to update this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', (array)$goal->departments->value()), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } $dataToUpdate = []; @@ -177,7 +188,10 @@ class departmentGoalsRoute if (!$isSuperuser && !$isCreator) { $missing = array_diff(array_map('intval', $departmentsInput), array_map('intval', $userDepartments)); if (count($missing) > 0) { - $response->error('You are not allowed to assign one or more selected departments to this goal', 403); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . (int)$departmentId, $missing), + 'superuser' + ]); } } // store as ids array in column @@ -247,7 +261,11 @@ class departmentGoalsRoute $hasAll = count(array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))) === 0; if (!$isSuperuser && !$isCreator && !$hasAll) { - $response->error('You are not allowed to delete this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } $goal->delete(); $response->success(['message' => 'Deleted']); @@ -282,7 +300,11 @@ class departmentGoalsRoute $goalDepartments = (array)$goal->departments->value(); $hasAll = count(array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))) === 0; if (!$isSuperuser && !$hasAll) { - $response->error('You are not allowed to send progress alerts for this goal', 403); + $missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments)))); + $response->forbidden([ + ...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments), + 'superuser' + ]); } // Rebuild criteria from stored array diff --git a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php index 3a8a6c95..9fcbfa53 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionRulesRoute.php @@ -40,7 +40,7 @@ class departmentSelfserveConditionRulesRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if ($condition_o->exists()) { if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value()) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value(), ['view_all_department_selfserve_condition_rules']); } } $response->success($rules_o->asArray()); @@ -59,7 +59,7 @@ class departmentSelfserveConditionRulesRoute if ($condition_o->exists()) { $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value()) && !$this->hasPermission('view_all_department_selfserve_condition_rules')) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value(), ['view_all_department_selfserve_condition_rules']); } } } @@ -119,7 +119,7 @@ class departmentSelfserveConditionRulesRoute } $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } try { @@ -167,7 +167,7 @@ class departmentSelfserveConditionRulesRoute $condition_o->select((int)$rule_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); if ($condition_o->exists() && !$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } if ($response->isRequestParameterSet('condition_id')) { @@ -178,7 +178,7 @@ class departmentSelfserveConditionRulesRoute $response->error('Target condition not found', 404); } if (!$this->canAccessDepartment($authorized_department_ids, (int)$new_condition_o->department->value())) { - $response->error('You do not have access to the target department', 403); + $this->forbidDepartmentAccess((int)$new_condition_o->department->value()); } $rule_o->condition_id->update($new_condition_id); } @@ -231,7 +231,7 @@ class departmentSelfserveConditionRulesRoute $condition_o->select((int)$rule_o->condition_id->value()); $authorized_department_ids = $user->getGroup()->getDepartments(); if ($condition_o->exists() && !$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } $rule_o->delete(); diff --git a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php index abbcc560..1980da48 100644 --- a/services/nginx/app/routes/departmentSelfserveConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveConditionsRoute.php @@ -37,7 +37,7 @@ class departmentSelfserveConditionsRoute if ($conditions_o->exists()) { if (!$this->canAccessDepartment($authorized_department_ids, (int)$conditions_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$conditions_o->department->value(), ['view_all_department_selfserve_conditions']); } } $response->success($conditions_o->asArray()); @@ -50,7 +50,7 @@ class departmentSelfserveConditionsRoute if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($requested_department, ['view_all_department_selfserve_conditions']); } $filters['department'] = $requested_department; } else { @@ -125,7 +125,7 @@ class departmentSelfserveConditionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, $department)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($department); } try { @@ -171,13 +171,13 @@ class departmentSelfserveConditionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } if ($response->isRequestParameterSet('department')) { $new_department = (int)$response->getRequestParameter('department'); if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { - $response->error('You do not have access to the target department', 403); + $this->forbidDepartmentAccess($new_department); } $condition_o->department->update($new_department); } @@ -232,7 +232,7 @@ class departmentSelfserveConditionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$condition_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } $condition_o->delete(); diff --git a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php index 3603e35e..5dd09cd1 100644 --- a/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveQuestionsRoute.php @@ -37,7 +37,7 @@ class departmentSelfserveQuestionsRoute if ($questions_o->exists()) { if (!$this->canAccessDepartment($authorized_department_ids, (int)$questions_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$questions_o->department->value(), ['view_all_department_selfserve_questions']); } } $response->success($questions_o->asArray()); @@ -50,7 +50,7 @@ class departmentSelfserveQuestionsRoute if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($requested_department, ['view_all_department_selfserve_questions']); } $filters['department'] = $requested_department; } else { @@ -116,7 +116,7 @@ class departmentSelfserveQuestionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, $department)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($department); } try { @@ -160,13 +160,13 @@ class departmentSelfserveQuestionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$question_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$question_o->department->value()); } if (self::isParametersSet(['department'])) { $new_department = (int)self::getParameter('department'); if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { - $response->error('You do not have access to the new department', 403); + $this->forbidDepartmentAccess($new_department); } $question_o->department->set($new_department); } @@ -218,7 +218,7 @@ class departmentSelfserveQuestionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$question_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$question_o->department->value()); } $question_o->delete(); diff --git a/services/nginx/app/routes/departmentSelfserveTasksRoute.php b/services/nginx/app/routes/departmentSelfserveTasksRoute.php index 1bcfa1e5..52663fb1 100644 --- a/services/nginx/app/routes/departmentSelfserveTasksRoute.php +++ b/services/nginx/app/routes/departmentSelfserveTasksRoute.php @@ -41,7 +41,7 @@ class departmentSelfserveTasksRoute if ($tasks_o->exists()) { if (!$this->canAccessDepartment($authorized_department_ids, (int)$tasks_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$tasks_o->department->value(), ['view_all_department_selfserve_tasks']); } } $response->success($tasks_o->asArray()); @@ -54,7 +54,7 @@ class departmentSelfserveTasksRoute if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); if (!$this->canAccessDepartment($authorized_department_ids, $requested_department) && !$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($requested_department, ['view_all_department_selfserve_tasks']); } $filters['department'] = $requested_department; } else { @@ -195,7 +195,7 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, $department)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($department); } try { @@ -243,13 +243,13 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value()); } if (self::isParametersSet(['department'])) { $new_department = (int)self::getParameter('department'); if (!$this->canAccessDepartment($authorized_department_ids, $new_department)) { - $response->error('You do not have access to the new department', 403); + $this->forbidDepartmentAccess($new_department); } $task_o->department->set($new_department); } @@ -372,7 +372,7 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value()); } $task_o->delete(); @@ -408,7 +408,7 @@ class departmentSelfserveTasksRoute $has_view_all_permission = $this->hasPermission('view_all_department_selfserve_tasks'); if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value(), ['view_all_department_selfserve_tasks']); } } @@ -454,7 +454,7 @@ class departmentSelfserveTasksRoute $has_view_all_permission = $this->hasPermission('view_all_department_selfserve_tasks'); if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { if (!$has_view_all_permission) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value(), ['view_all_department_selfserve_tasks']); } } @@ -491,7 +491,7 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value()); } $attachment_store = new attachment_store(); @@ -536,7 +536,7 @@ class departmentSelfserveTasksRoute $authorized_department_ids = $user->getGroup()->getDepartments(); if (!$this->canAccessDepartment($authorized_department_ids, (int)$task_o->department->value())) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$task_o->department->value()); } $task_o->removeAttachment($attachment_id); diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index 8f034630..53750c0b 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -35,7 +35,7 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); } (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'LIST_VEHICLE_CONDITIONS', 'User listed department self-serve vehicle conditions'); @@ -49,11 +49,11 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); if (!in_array((int)$conditions_o->department->value(), $authorized_department_ids, true)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$conditions_o->department->value()); } } else { if ((int)$conditions_o->customer_id->value() !== $customer_number) { - $response->error('You do not have access to this condition', 403); + $response->forbidden(['list_department_selfserve_vehicle_conditions']); } } $response->success($conditions_o->asArray()); @@ -68,7 +68,7 @@ class departmentSelfserveVehicleConditionsRoute if (self::isParametersSet(['department'])) { $requested_department = (int)self::getParameter('department'); if (!in_array($requested_department, $authorized_department_ids, true)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($requested_department); } $filters['department'] = $requested_department; } else { @@ -123,7 +123,7 @@ class departmentSelfserveVehicleConditionsRoute $has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions'); $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); } self::requireParameters(['lane_id', 'reg']); @@ -133,7 +133,7 @@ class departmentSelfserveVehicleConditionsRoute $lane = $this->assertLaneAccess($user, $lane_id, $has_global); $customer_number = null; if (!$has_global && $has_own) { - $vehicle = $this->assertOwnVehicle($user, $reg); + $vehicle = $this->assertOwnVehicle($user, $reg, 'list_department_selfserve_vehicle_conditions'); $customer_number = (int)$vehicle->customer_id->value(); } @@ -157,14 +157,14 @@ class departmentSelfserveVehicleConditionsRoute $has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions'); $has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); } $flow = $this->getWashFlow(); try { if (self::isParametersSet(['session_id'])) { $summary = $flow->getSessionSummary((int)self::getParameter('session_id')); - $this->assertSummaryAccess($user, $summary, $has_global); + $this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions'); $response->success($summary); } @@ -174,11 +174,11 @@ class departmentSelfserveVehicleConditionsRoute $this->assertLaneAccess($user, $lane_id, $has_global); if (!$has_global && $has_own) { - $this->assertOwnVehicle($user, $reg); + $this->assertOwnVehicle($user, $reg, 'list_department_selfserve_vehicle_conditions'); } $summary = $flow->getLatestSessionSummary($lane_id, $reg); - $this->assertSummaryAccess($user, $summary, $has_global); + $this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions'); $response->success($summary); } catch (\RuntimeException $e) { $response->error($e->getMessage(), 404); @@ -202,7 +202,7 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('add_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']); } $department = (int)$response->getRequestParameter('department'); @@ -219,11 +219,11 @@ class departmentSelfserveVehicleConditionsRoute $customer_id = $response->isRequestParameterSet('customer_id') ? (int)$response->getRequestParameter('customer_id') : null; $authorized_department_ids = $user->getGroup()->getDepartments(); if (!in_array($department, $authorized_department_ids, true)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($department); } } else { $customer_id = (int)$user->customer_number->value(); - $vehicle_o = $this->assertOwnVehicle($user, $reg); + $vehicle_o = $this->assertOwnVehicle($user, $reg, 'add_department_selfserve_vehicle_conditions'); $customer_id = (int)$vehicle_o->customer_id->value(); } @@ -258,7 +258,7 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('update_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['update_department_selfserve_vehicle_conditions', 'update_own_department_selfserve_vehicle_conditions']); } $id = (int)$response->getRequestParameter('id'); @@ -277,11 +277,11 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); if (!in_array((int)$condition_o->department->value(), $authorized_department_ids, true)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } } else { if ((int)$condition_o->customer_id->value() !== $customer_number) { - $response->error('You do not have access to this condition', 403); + $response->forbidden(['update_department_selfserve_vehicle_conditions']); } } @@ -290,7 +290,7 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); if (!in_array($new_department, $authorized_department_ids, true)) { - $response->error('You do not have access to the target department', 403); + $this->forbidDepartmentAccess($new_department); } } $condition_o->department->update($new_department); @@ -301,9 +301,9 @@ class departmentSelfserveVehicleConditionsRoute if ($response->isRequestParameterSet('reg')) { $new_reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg')); if (!$has_global && $has_own) { - $vehicle_o = $this->assertOwnVehicle($user, $new_reg); + $vehicle_o = $this->assertOwnVehicle($user, $new_reg, 'update_department_selfserve_vehicle_conditions'); if ((int)$vehicle_o->customer_id->value() !== $customer_number) { - $response->error('You do not own this vehicle', 403); + $response->forbidden(['update_department_selfserve_vehicle_conditions']); } } $condition_o->reg->update($new_reg); @@ -317,7 +317,7 @@ class departmentSelfserveVehicleConditionsRoute if ($response->isRequestParameterSet('customer_id')) { $new_customer_id = (int)$response->getRequestParameter('customer_id'); if (!$has_global && $has_own && $new_customer_id !== $customer_number) { - $response->error('You cannot change the customer ID to another customer', 403); + $response->forbidden(['update_department_selfserve_vehicle_conditions']); } $condition_o->customer_id->update($new_customer_id); } @@ -355,7 +355,7 @@ class departmentSelfserveVehicleConditionsRoute $has_own = $user->hasPermission('delete_own_department_selfserve_vehicle_conditions'); if (!$has_global && !$has_own) { - $response->error('Permission denied', 403); + $response->forbidden(['delete_department_selfserve_vehicle_conditions', 'delete_own_department_selfserve_vehicle_conditions']); } $id = (int)$response->getRequestParameter('id'); @@ -372,11 +372,11 @@ class departmentSelfserveVehicleConditionsRoute if ($has_global) { $authorized_department_ids = $user->getGroup()->getDepartments(); if (!in_array((int)$condition_o->department->value(), $authorized_department_ids, true)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$condition_o->department->value()); } } else { if ((int)$condition_o->customer_id->value() !== (int)$user->customer_number->value()) { - $response->error('You do not have access to this condition', 403); + $response->forbidden(['delete_department_selfserve_vehicle_conditions']); } } @@ -420,26 +420,26 @@ class departmentSelfserveVehicleConditionsRoute if ($hasGlobalPermission) { $authorized_department_ids = $user->getGroup()->getDepartments(); if (!in_array((int)$lane->department->value(), $authorized_department_ids, true)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess((int)$lane->department->value()); } } return $lane; } - private function assertOwnVehicle(object $user, string $reg): customer_vehicles_o + private function assertOwnVehicle(object $user, string $reg, ?string $elevatedPermission = null): customer_vehicles_o { global $response; $vehicle_o = (new customer_vehicles_o())->selectByPlate($reg); if (!$vehicle_o->exists() || (int)$vehicle_o->customer_id->value() !== (int)$user->customer_number->value()) { - $response->error('You do not own this vehicle', 403); + $response->forbidden([$elevatedPermission ?? 'list_department_selfserve_vehicle_conditions']); } return $vehicle_o; } - private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission): void + private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission, string $elevatedPermission): void { global $response; @@ -447,7 +447,7 @@ class departmentSelfserveVehicleConditionsRoute $authorized_department_ids = $user->getGroup()->getDepartments(); $lane_department = (int)($summary['lane']['department'] ?? 0); if (!in_array($lane_department, $authorized_department_ids, true)) { - $response->error('You do not have access to this department', 403); + $this->forbidDepartmentAccess($lane_department); } return; } @@ -463,6 +463,6 @@ class departmentSelfserveVehicleConditionsRoute return; } - $response->error('You do not have access to this wash summary', 403); + $response->forbidden([$elevatedPermission]); } } diff --git a/services/nginx/app/routes/intimidateRoute.php b/services/nginx/app/routes/intimidateRoute.php index 25a275b1..4ec39c84 100644 --- a/services/nginx/app/routes/intimidateRoute.php +++ b/services/nginx/app/routes/intimidateRoute.php @@ -17,9 +17,7 @@ class intimidateRoute // Get the post data global $response; // Make sure the user has the SUPERUSER_INTIMIDATE permission - if (!$this->requirePermission('SUPERUSER_INTIMIDATE')) { - $response->error('Permission denied', 403); - } + $this->requirePermission('SUPERUSER_INTIMIDATE'); // Get the user object $user = (new authentication())->get_user(); // Get the post data @@ -42,4 +40,4 @@ class intimidateRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index 87074bb6..3acf04b2 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -100,7 +100,7 @@ class orderItemsRoute $hasPermission = $this->hasPermission('list_own_order_items'); $hasAttribute = $user->showPricesOnBookingPage(); // Check if the user has the attribute to show prices on the booking page if (!$hasPermission && !$hasAttribute) { - $response->error('You do not have permission to list order items, neither your own nor all orders', 403); + $response->forbidden(['list_own_order_items', 'list_order_items']); } } else { $this->requirePermission('list_order_items'); @@ -223,4 +223,4 @@ class orderItemsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index a05a6a97..447d40d1 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -306,7 +306,7 @@ class ordersRoute if (!$has_permission_other) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { - $response->error('You do not have permission to download this order attachment', 403); + $response->forbidden([$permission_other->permission]); } } // Get the attachment @@ -368,7 +368,7 @@ class ordersRoute self::requirePermission($permission_own); $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { - $response->error('You do not have permission to view attachments for this order.', 403); + $response->forbidden([$permission_other->permission]); } } // Get the attachments @@ -882,12 +882,12 @@ class ordersRoute if ($subuser_own_path) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { - $response->error('You do not have permission to edit this order', 403); + $response->forbidden([$permission_other->permission]); } } else { // Classic own path — compare against authenticated user customer number if ($order->customer_id->value() !== $user->customer_number->value()) { - $response->error('You do not have permission to edit this order', 403); + $response->forbidden([$permission_other->permission]); } // Ensure classic own-path requires user permission $this->requirePermission('user'); @@ -1038,4 +1038,4 @@ class ordersRoute // Optional fields are not checked here, as they are optional and can be empty return $data; } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 2c272bb7..3906eb26 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -79,7 +79,7 @@ class subusersRoute // If not admin/department permission, force restrict to effective customer if (!$has_permission_other) { if ($effectiveCustomer === null) { - $response->error('Missing customer context. Subusers must provide X-Customer-Number header.', 403); + $response->forbidden([$permission_other->permission]); } $filters['billing_customer_number'] = (int)$effectiveCustomer; $hasFilter = true; // ensure we don't fail below @@ -673,4 +673,4 @@ class subusersRoute $response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]); }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/systemSearchRoute.php b/services/nginx/app/routes/systemSearchRoute.php index 2c001125..467047e9 100644 --- a/services/nginx/app/routes/systemSearchRoute.php +++ b/services/nginx/app/routes/systemSearchRoute.php @@ -92,7 +92,7 @@ class systemSearchRoute [$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes(); if (empty($allowedTypes)) { - $response->error('Permission denied. No searchable entity types available for this user.', 403); + $response->forbidden($this->searchAccessPermissionCandidates()); } $permissionsCatalogAll = $this->flattenPermissionCatalog((array)$router->getPermissions()); @@ -578,4 +578,27 @@ class systemSearchRoute { return array_keys($this->entityPermissionMap()); } + + /** + * Return all permission keys that can unlock at least one searchable entity type. + * + * @return array + */ + private function searchAccessPermissionCandidates(): array + { + $permissions = []; + foreach ($this->entityPermissionMap() as $permissionSets) { + foreach ((array)($permissionSets['all'] ?? []) as $permission) { + if (is_string($permission) && $permission !== '') { + $permissions[] = $permission; + } + } + foreach ((array)($permissionSets['own'] ?? []) as $permission) { + if (is_string($permission) && $permission !== '') { + $permissions[] = $permission; + } + } + } + return array_values(array_unique($permissions)); + } } diff --git a/services/nginx/app/tests/Unit/Permissions/AllowOwnOrDepartmentAccessForbiddenTest.php b/services/nginx/app/tests/Unit/Permissions/AllowOwnOrDepartmentAccessForbiddenTest.php new file mode 100644 index 00000000..cc4ffc14 --- /dev/null +++ b/services/nginx/app/tests/Unit/Permissions/AllowOwnOrDepartmentAccessForbiddenTest.php @@ -0,0 +1,92 @@ + */ + public array $permissionsByKey = []; + public bool $ownContext = true; + /** @var array|null */ + public ?array $lastForbidden = null; + public bool $departmentAccessChecked = false; + + public function __construct() + { + // no-op for unit tests + } + + public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool + { + $key = is_string($permission) ? $permission : (string)$permission->permission; + return (bool)($this->permissionsByKey[$key] ?? false); + } + + public function isOwnCustomerContext(int $targetCustomerNumber): bool + { + return $this->ownContext; + } + + public function requireDepartmentAccess(string $department, string|null $permission = null): void + { + $this->departmentAccessChecked = true; + } + + protected function emitForbidden(array $permissions): void + { + $normalized = []; + foreach ($permissions as $permission) { + $key = is_string($permission) ? trim($permission) : trim((string)$permission->permission); + if ($key !== '') { + $normalized[] = $key; + } + } + $this->lastForbidden = array_values(array_unique($normalized)); + throw new RuntimeException('forbidden'); + } +} + +it('returns both own and elevated permissions when both are missing', function (): void { + $host = new AllowOwnOrDepartmentAccessForbiddenHost(); + $host->permissionsByKey = [ + 'perm_own' => false, + 'perm_other' => false, + ]; + + expect(fn() => $host->allowOwnOrDepartmentAccess('perm_own', 'perm_other', 101, null)) + ->toThrow(RuntimeException::class, 'forbidden'); + + expect($host->lastForbidden)->toBe(['perm_own', 'perm_other']); +}); + +it('returns only elevated permission when own permission exists but own context fails', function (): void { + $host = new AllowOwnOrDepartmentAccessForbiddenHost(); + $host->permissionsByKey = [ + 'perm_own' => true, + 'perm_other' => false, + ]; + $host->ownContext = false; + + expect(fn() => $host->allowOwnOrDepartmentAccess('perm_own', 'perm_other', 202, null)) + ->toThrow(RuntimeException::class, 'forbidden'); + + expect($host->lastForbidden)->toBe(['perm_other']); +}); + +it('allows elevated permission path without forbidden and validates department access', function (): void { + $host = new AllowOwnOrDepartmentAccessForbiddenHost(); + $host->permissionsByKey = [ + 'perm_own' => false, + 'perm_other' => true, + ]; + + $allowed = $host->allowOwnOrDepartmentAccess('perm_own', 'perm_other', 303, 77); + + expect($allowed)->toBeTrue(); + expect($host->departmentAccessChecked)->toBeTrue(); + expect($host->lastForbidden)->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Permissions/ForbiddenResponseWiringTest.php b/services/nginx/app/tests/Unit/Permissions/ForbiddenResponseWiringTest.php new file mode 100644 index 00000000..8448f4cb --- /dev/null +++ b/services/nginx/app/tests/Unit/Permissions/ForbiddenResponseWiringTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($routeTrait)->toContain('protected function emitForbidden(array $permissions): void'); + expect($routeTrait)->toContain('$response->forbidden($this->normalizePermissionKeys($permissions));'); + expect($routeTrait)->toContain('$this->emitForbidden($missingPermissions);'); + + $selfserveRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + expect($selfserveRoute)->not->toBeFalse(); + expect($selfserveRoute)->toContain("forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions'])"); + expect($selfserveRoute)->toContain('forbidDepartmentAccess('); + + $ordersRoute = file_get_contents(app_path('routes/ordersRoute.php')); + expect($ordersRoute)->not->toBeFalse(); + expect($ordersRoute)->toContain('$response->forbidden([$permission_other->permission]);'); + expect($ordersRoute)->not->toContain("\$response->error('You do not have permission to edit this order', 403);"); +}); diff --git a/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php b/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php index 7eeda793..c50c74f8 100644 --- a/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php +++ b/services/nginx/app/tests/permissions/PermissionRedisCacheTest.php @@ -16,6 +16,7 @@ namespace classes { } class response { public function error($msg, $code) { throw new \Exception("Response Error ($code): $msg"); } + public function forbidden(array $permissions) { throw new \Exception("Response Error (403): Missing permission(s): " . implode(', ', $permissions)); } public function add_meta($k, $v) {} } class permission_node { diff --git a/services/nginx/app/traits/route_t.php b/services/nginx/app/traits/route_t.php index b5d0e587..919cc4fa 100644 --- a/services/nginx/app/traits/route_t.php +++ b/services/nginx/app/traits/route_t.php @@ -321,6 +321,71 @@ trait route_t return null; } + /** + * Convert a permission definition to its canonical string key. + */ + private function permissionKey(string|permission_node $permission): string + { + return $permission instanceof permission_node + ? (string)$permission->permission + : (string)$permission; + } + + /** + * Normalize and de-duplicate permission keys for forbidden responses. + * Accepts string keys and permission_node definitions. + * + * @param array $permissions + * @return array + */ + private function normalizePermissionKeys(array $permissions): array + { + $keys = []; + foreach ($permissions as $permission) { + if ($permission instanceof permission_node) { + $key = trim((string)$permission->permission); + } elseif (is_string($permission)) { + $key = trim($permission); + } else { + continue; + } + if ($key !== '') { + $keys[] = $key; + } + } + return array_values(array_unique($keys)); + } + + /** + * Emit standardized forbidden response payload with missing permission keys. + * Extracted to allow focused unit tests by overriding this method. + * + * @param array $permissions + */ + protected function emitForbidden(array $permissions): void + { + global $response; + $response->forbidden($this->normalizePermissionKeys($permissions)); + } + + /** + * Emit a forbidden response for missing department scope access. + * Optionally include bypass permissions if they are relevant and missing. + * + * @param int $departmentId + * @param array $optionalBypassPermissions + */ + public function forbidDepartmentAccess(int $departmentId, array $optionalBypassPermissions = []): void + { + $missing = ['department_access_' . $departmentId]; + foreach ($optionalBypassPermissions as $permission) { + if (!$this->hasPermission($permission)) { + $missing[] = $permission; + } + } + $this->emitForbidden($missing); + } + /** * Centralized permission evaluation used by both requirePermission and hasPermission. * - Honors subusers permission nodes without falling back to classic user permissions when a node is defined. @@ -340,7 +405,7 @@ trait route_t if ($resolvedCustomer === null) { (new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Missing customer context for subuser permission evaluation: ' . $permission->permission); if ($throwOnDeny) { - $response->error('Permission denied. Missing customer context for subuser.', 403); + $this->emitForbidden([$permission]); } return false; } @@ -367,7 +432,7 @@ trait route_t if (!$subuser_has_permission && $throwOnDeny) { (new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Permission denied via subuser node: ' . $permission->permission . ' (Node: ' . $permission->subusers_node_key->name . ', Customer: ' . $resolvedCustomer . ')'); - $response->error('Permission denied for subuser. Missing permission: ' . $permission->permission . ' (Customer context: ' . $resolvedCustomer . ')', 403); + $this->emitForbidden([$permission]); } return $subuser_has_permission; } @@ -404,7 +469,7 @@ trait route_t if (!$allowed && $throwOnDeny) { (new logs_o())->add('global', 'global', 1, $user->id, 'PERMISSION_DENIED', 'Permission denied. Missing permission: ' . $perm_string); - $response->error('Permission denied. Missing permission: ' . $perm_string . ' for user: ' . $user->id . ' In group: ' . $user->group_id->value(), 403); + $this->emitForbidden([$perm_string]); } return $allowed; } catch (Exception $e) { @@ -528,7 +593,21 @@ trait route_t } if (!$allowed) { - $response->error($denyMessage ?? 'Permission denied.', 403); + $missingPermissions = []; + if (!$hasOwn) { + $missingPermissions[] = $permissionOwn; + } + if (!$hasOther) { + $missingPermissions[] = $permissionOther; + } + // In own-scope failures (wrong customer/guard failure), report missing elevated permission only. + if ($hasOwn && !$hasOther) { + $missingPermissions[] = $permissionOther; + } + if (count($missingPermissions) === 0) { + $missingPermissions[] = $permissionOther; + } + $this->emitForbidden($missingPermissions); } return $allowed; } @@ -816,4 +895,4 @@ trait route_t // Check if route is the same, or if it matches the regex pattern return $route === $this->route || preg_match($route, $this->route); } -} \ No newline at end of file +}