Adds a scope-based access control layer to all 81 existing API routes. Sits alongside existing session-cookie auth (does not replace it). What this PR does: - Audits every existing route and documents required scope per route (see documentation/auth/route-scope-audit.md) - Adds classes/auth/scope.php with 10 scope constants and role→scope defaults - Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole - Applies require*() calls to all 81 existing routes - Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines) Coexistence note: This branch's classes/auth/scope.php is a stub that will be replaced by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that PR merges first. The two have compatible APIs. Refs: TRU-149
627 lines
22 KiB
PHP
627 lines
22 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\response;
|
|
use classes\router;
|
|
use classes\system_search_cache;
|
|
use classes\system_search_service;
|
|
use Throwable;
|
|
use traits\route_t;
|
|
|
|
use app\auth\Scope;
|
|
use app\auth\ScopeMiddleware;
|
|
|
|
class systemSearchRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
$this->get('/search/system', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/search/system');
|
|
$this->handleSearchRequest();
|
|
});
|
|
|
|
$this->post('/search/system', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/search/system');
|
|
$this->handleSearchRequest();
|
|
});
|
|
|
|
$this->delete('/superuser/search/system/cache', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/search/system/cache');
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('superuser_search_system_cache_clear');
|
|
|
|
system_search_cache::clearAll();
|
|
$response->success([
|
|
'message' => 'System search cache cleared',
|
|
'query_cache_cleared' => true,
|
|
]);
|
|
}, [
|
|
'superuser_search_system_cache_clear' => 'Clear system-wide search query caches',
|
|
]);
|
|
|
|
$this->post('/superuser/search/system/cache/rebuild', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/search/system/cache/rebuild');
|
|
global /** @var response $response */
|
|
$response;
|
|
$this->requirePermission('superuser_search_system_cache_rebuild');
|
|
|
|
$params = $this->getRequestPayload();
|
|
$scope = strtolower(trim((string)($params['scope'] ?? 'all')));
|
|
$types = $this->parseTypeList($params['types'] ?? []);
|
|
$request = system_search_cache::enqueueRebuild($scope, $types);
|
|
|
|
system_search_cache::clearQueryCaches();
|
|
|
|
$response->success([
|
|
'message' => 'System search cache rebuild queued',
|
|
'request' => $request,
|
|
'query_cache_cleared' => true,
|
|
]);
|
|
}, [
|
|
'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild',
|
|
]);
|
|
}
|
|
|
|
private function handleSearchRequest(): void
|
|
{
|
|
global /** @var response $response */
|
|
/** @var router $router */
|
|
$response, $router;
|
|
|
|
$auth = new authentication();
|
|
$user = $auth->get_user();
|
|
$subuser = $auth->get_subuser();
|
|
if ($user === false && $subuser === false) {
|
|
$response->error('Invalid session', 401);
|
|
}
|
|
|
|
$params = $this->getRequestPayload();
|
|
$query = trim((string)($params['q'] ?? $params['query'] ?? $params['search'] ?? ''));
|
|
if ($query === '') {
|
|
$response->error('Missing required parameter: query', 400);
|
|
}
|
|
|
|
$includeTypes = $this->parseTypeList($params['include_types'] ?? []);
|
|
$excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []);
|
|
$includeAssociations = $this->toBool($params['include_associations'] ?? true, true);
|
|
$maxResults = $this->clampMaxResults((int)($params['max_results'] ?? 50));
|
|
|
|
[$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes();
|
|
if (empty($allowedTypes)) {
|
|
$response->forbidden($this->searchAccessPermissionCandidates());
|
|
}
|
|
|
|
$permissionsCatalogAll = $this->flattenPermissionCatalog((array)$router->getPermissions());
|
|
$permissionsCatalogOwn = [];
|
|
if ($user !== false) {
|
|
try {
|
|
$permissionsCatalogOwn = array_values(array_unique(array_map('strval', (array)$user->getGroup()->getPermissions())));
|
|
} catch (Throwable) {
|
|
$permissionsCatalogOwn = [];
|
|
}
|
|
}
|
|
|
|
$service = new system_search_service();
|
|
$result = $service->search([
|
|
'query' => $query,
|
|
'include_types' => $includeTypes,
|
|
'exclude_types' => $excludeTypes,
|
|
'allowed_types' => $allowedTypes,
|
|
'own_only_types' => $ownOnlyTypes,
|
|
'own_customer_number' => $this->resolveEffectiveCustomerNumber(),
|
|
'allowed_department_ids' => $this->resolveAllowedDepartmentIds($user),
|
|
'permissions_catalog_all' => $permissionsCatalogAll,
|
|
'permissions_catalog_own' => $permissionsCatalogOwn,
|
|
'module_config_visibility' => $this->buildModuleConfigVisibility(),
|
|
'include_associations' => $includeAssociations,
|
|
'max_results' => $maxResults,
|
|
]);
|
|
|
|
$response->success($result);
|
|
}
|
|
|
|
private function getRequestPayload(): array
|
|
{
|
|
$payload = $this->getParametersAsArray();
|
|
return is_array($payload) ? $payload : [];
|
|
}
|
|
|
|
private function resolveAllowedTypes(): array
|
|
{
|
|
$allowed = [];
|
|
$ownOnly = [];
|
|
foreach ($this->entityPermissionMap() as $type => $permissionSets) {
|
|
$hasAll = $this->hasAnyPermission((array)($permissionSets['all'] ?? []));
|
|
$hasOwn = $this->hasAnyPermission((array)($permissionSets['own'] ?? []));
|
|
if (!$hasAll && !$hasOwn) {
|
|
continue;
|
|
}
|
|
$allowed[] = $type;
|
|
if (!$hasAll && $hasOwn) {
|
|
$ownOnly[] = $type;
|
|
}
|
|
}
|
|
return [
|
|
array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($allowed)))),
|
|
array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($ownOnly)))),
|
|
];
|
|
}
|
|
|
|
private function entityPermissionMap(): array
|
|
{
|
|
return [
|
|
'objects' => [
|
|
'all' => ['list_order_attachments', 'download_order_attachments', 'list_department_selfserve_task_attachments'],
|
|
'own' => ['list_own_order_attachments', 'download_order_attachments_own'],
|
|
],
|
|
'module_config' => [
|
|
'all' => $this->moduleConfigPermissions(),
|
|
'own' => [],
|
|
],
|
|
'orders' => [
|
|
'all' => ['list_orders', 'fetch_order'],
|
|
'own' => ['list_own_orders', 'fetch_own_order'],
|
|
],
|
|
'order_items' => [
|
|
'all' => ['list_order_items'],
|
|
'own' => ['list_own_order_items'],
|
|
],
|
|
'customers' => [
|
|
'all' => ['search_customers', 'list_users', 'get_user_from_customer_number'],
|
|
'own' => ['user'],
|
|
],
|
|
'employees' => [
|
|
'all' => ['list_users'],
|
|
'own' => [],
|
|
],
|
|
'users' => [
|
|
'all' => ['list_users', 'get_user'],
|
|
'own' => ['user'],
|
|
],
|
|
'subusers' => [
|
|
'all' => ['list_subuser_grants', 'manage_subuser_grants'],
|
|
'own' => ['list_own_subusers', 'list_own_subuser_grants'],
|
|
],
|
|
'customer_discounts' => [
|
|
'all' => ['get_custom_prices_other', 'set_custom_price'],
|
|
'own' => [],
|
|
],
|
|
'customer_fixed_prices' => [
|
|
'all' => ['get_customer_fixed_pricing'],
|
|
'own' => [],
|
|
],
|
|
'departments' => [
|
|
'all' => ['list_departments'],
|
|
'own' => [],
|
|
],
|
|
'permissions' => [
|
|
'all' => ['permissions_list'],
|
|
'own' => ['permissions_list_own'],
|
|
],
|
|
'roles' => [
|
|
'all' => ['list_roles'],
|
|
'own' => [],
|
|
],
|
|
'invoices' => [
|
|
'all' => ['list_collected_invoices', 'list_collected_invoices_economic_overview'],
|
|
'own' => ['user_invoices'],
|
|
],
|
|
'vehicles' => [
|
|
'all' => ['list_vehicles_other', 'list_unknown_customer_vehicles'],
|
|
'own' => ['list_own_vehicles'],
|
|
],
|
|
'bookings' => [
|
|
'all' => ['list_bookings'],
|
|
'own' => ['list_own_bookings'],
|
|
],
|
|
'bookings_new' => [
|
|
'all' => ['list_bookings', 'statistics_bookings_new'],
|
|
'own' => ['list_own_bookings'],
|
|
],
|
|
'branding' => [
|
|
'all' => ['list_branding_options'],
|
|
'own' => [],
|
|
],
|
|
'categories' => [
|
|
'all' => ['list_categories'],
|
|
'own' => [],
|
|
],
|
|
'currency_conversion_rates' => [
|
|
'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'],
|
|
'own' => [],
|
|
],
|
|
'customer_codes' => [
|
|
'all' => ['get_customer_code', 'add_customer_code'],
|
|
'own' => [],
|
|
],
|
|
'customer_default_department' => [
|
|
'all' => ['get_customer_default_department_other', 'add_customer_default_department_other', 'delete_customer_default_department_other'],
|
|
'own' => ['get_customer_default_department', 'add_customer_default_department', 'delete_customer_default_department'],
|
|
],
|
|
'customer_notes' => [
|
|
'all' => ['list_customer_notes'],
|
|
'own' => [],
|
|
],
|
|
'customer_vehicles_addons' => [
|
|
'all' => ['list_vehicles_addon_other', 'list_vehicle_customer_suggestions'],
|
|
'own' => ['list_vehicle_addon_own'],
|
|
],
|
|
'department_categories' => [
|
|
'all' => ['list_department_categories'],
|
|
'own' => [],
|
|
],
|
|
'department_daily_reports' => [
|
|
'all' => ['list_department_daily_reports'],
|
|
'own' => [],
|
|
],
|
|
'department_gates' => [
|
|
'all' => ['list_department_gates'],
|
|
'own' => [],
|
|
],
|
|
'department_goals' => [
|
|
'all' => ['goals_department_list'],
|
|
'own' => [],
|
|
],
|
|
'department_lanes' => [
|
|
'all' => ['list_department_lanes'],
|
|
'own' => [],
|
|
],
|
|
'department_notification_sms' => [
|
|
'all' => ['department_notification_sms_get'],
|
|
'own' => [],
|
|
],
|
|
'department_relays' => [
|
|
'all' => ['list_department_relays'],
|
|
'own' => [],
|
|
],
|
|
'department_selfserve_condition_rules' => [
|
|
'all' => ['list_department_selfserve_condition_rules'],
|
|
'own' => [],
|
|
],
|
|
'department_selfserve_conditions' => [
|
|
'all' => ['list_department_selfserve_conditions'],
|
|
'own' => [],
|
|
],
|
|
'department_selfserve_questions' => [
|
|
'all' => ['list_department_selfserve_questions'],
|
|
'own' => [],
|
|
],
|
|
'department_selfserve_tasks' => [
|
|
'all' => ['list_department_selfserve_tasks'],
|
|
'own' => [],
|
|
],
|
|
'department_selfserve_vehicle_conditions' => [
|
|
'all' => ['list_department_selfserve_vehicle_conditions'],
|
|
'own' => ['list_own_department_selfserve_vehicle_conditions'],
|
|
],
|
|
'department_time_bookings_entries' => [
|
|
'all' => ['department_timebookings_entries_get'],
|
|
'own' => [],
|
|
],
|
|
'department_time_bookings_opening_hours' => [
|
|
'all' => ['department_timebookings_opening_hours_get'],
|
|
'own' => [],
|
|
],
|
|
'department_time_bookings_types' => [
|
|
'all' => ['department_timebookings_types_get'],
|
|
'own' => [],
|
|
],
|
|
'department_variables' => [
|
|
'all' => ['superuser_fetch_department_variables', 'superuser_set_department_variables'],
|
|
'own' => [],
|
|
],
|
|
'fxratesapi_conversion_rates' => [
|
|
'all' => ['modules_fxratesapi_rate', 'modules_fxratesapi_rates'],
|
|
'own' => [],
|
|
],
|
|
'module_action_logs' => [
|
|
'all' => ['modules_action_logs_view'],
|
|
'own' => [],
|
|
],
|
|
'motorapi_lookups' => [
|
|
'all' => ['modules_motorapi_lookup', 'department_license_plate_lookup'],
|
|
'own' => [],
|
|
],
|
|
'notifications' => [
|
|
'all' => ['list_all_notifications', 'list_notifications'],
|
|
'own' => ['list_own_notifications'],
|
|
],
|
|
'order_bookings' => [
|
|
'all' => ['list_bookings'],
|
|
'own' => ['list_own_bookings'],
|
|
],
|
|
'plate_scanners' => [
|
|
'all' => ['list_number_plate_scanners', 'list_department_number_plate_scanners'],
|
|
'own' => [],
|
|
],
|
|
'plate_scans' => [
|
|
'all' => ['list_number_plate_scans', 'list_number_plate_scans_department'],
|
|
'own' => [],
|
|
],
|
|
'product_options' => [
|
|
'all' => ['list_product_options'],
|
|
'own' => [],
|
|
],
|
|
'products' => [
|
|
'all' => ['list_products', 'economic_products_get'],
|
|
'own' => [],
|
|
],
|
|
'stripe_module_customers' => [
|
|
'all' => ['modules_stripe_customers_list'],
|
|
'own' => [],
|
|
],
|
|
'stripe_module_orders' => [
|
|
'all' => ['list_orders', 'fetch_order'],
|
|
'own' => ['list_own_orders', 'fetch_own_order'],
|
|
],
|
|
'stripe_payment_intents' => [
|
|
'all' => ['get_payment_intent', 'confirm_payment_intent'],
|
|
'own' => [],
|
|
],
|
|
'subuser_grants' => [
|
|
'all' => ['list_subuser_grants', 'manage_subuser_grants'],
|
|
'own' => ['list_own_subuser_grants'],
|
|
],
|
|
'xlvask_customers' => [
|
|
'all' => ['modules_xlvask_customers'],
|
|
'own' => [],
|
|
],
|
|
'xlvask_potential_order_matches' => [
|
|
'all' => ['list_potential_order_matches'],
|
|
'own' => ['list_own_potential_order_matches'],
|
|
],
|
|
'xlvask_usage_log_wash_items' => [
|
|
'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'],
|
|
'own' => ['list_xlvask_usage_orders_own'],
|
|
],
|
|
'xlvask_usage_logs' => [
|
|
'all' => ['modules_xlvask_usageLog', 'list_xlvask_usage_orders_all'],
|
|
'own' => ['list_xlvask_usage_orders_own'],
|
|
],
|
|
'xlvask_vehicle_types' => [
|
|
'all' => ['modules_xlvask_internal_vehicle_types'],
|
|
'own' => [],
|
|
],
|
|
'xlvask_vehicles' => [
|
|
'all' => ['modules_xlvask_vehicles'],
|
|
'own' => [],
|
|
],
|
|
];
|
|
}
|
|
|
|
private function moduleConfigPermissions(): array
|
|
{
|
|
return [
|
|
'economic_config',
|
|
'recaptcha_config',
|
|
'email_config',
|
|
'backups_config',
|
|
'modules_bird_config',
|
|
'motorapi_config',
|
|
'stripe_config',
|
|
'fxratesapi_config',
|
|
'weatherapi_config',
|
|
'gatewayapi_config',
|
|
'xlvask_config',
|
|
'entra_config',
|
|
'modules_limble_config',
|
|
'modules_ocrspace_config',
|
|
'modules_openai_config',
|
|
'modules_licenseplaterecognizer_config',
|
|
'modules_virkdata_config',
|
|
'modules_shelly_config',
|
|
'modules_selfserve_config',
|
|
];
|
|
}
|
|
|
|
private function buildModuleConfigVisibility(): array
|
|
{
|
|
global $db;
|
|
|
|
$modulePermissions = [
|
|
'economic' => ['economic_config'],
|
|
'reCAPTCHA' => ['recaptcha_config'],
|
|
'Email' => ['email_config'],
|
|
'Backups' => ['backups_config'],
|
|
'bird' => ['modules_bird_config'],
|
|
'motorapi' => ['motorapi_config'],
|
|
'Stripe' => ['stripe_config'],
|
|
'fxratesapi' => ['fxratesapi_config'],
|
|
'weatherapi' => ['weatherapi_config'],
|
|
'GatewayAPI' => ['gatewayapi_config'],
|
|
'xlvask' => ['xlvask_config'],
|
|
'Entra' => ['entra_config'],
|
|
'limble' => ['modules_limble_config'],
|
|
'ocrSpace' => ['modules_ocrspace_config'],
|
|
'openAI' => ['modules_openai_config'],
|
|
'licenseplaterecognizer' => ['modules_licenseplaterecognizer_config'],
|
|
'virkdata' => ['modules_virkdata_config'],
|
|
'shelly' => ['modules_shelly_config'],
|
|
'selfserve' => ['modules_selfserve_config'],
|
|
];
|
|
|
|
$visibility = [];
|
|
try {
|
|
$result = $db->query('SELECT DISTINCT module FROM module_config');
|
|
if ($result instanceof \mysqli_result) {
|
|
$rows = $db->fetch_all($result);
|
|
foreach ($rows as $row) {
|
|
$module = (string)($row['module'] ?? '');
|
|
if ($module === '') {
|
|
continue;
|
|
}
|
|
$candidates = $modulePermissions[$module] ?? [];
|
|
if (empty($candidates)) {
|
|
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '', $module) ?? '');
|
|
if ($slug !== '') {
|
|
$candidates[] = $slug . '_config';
|
|
$candidates[] = 'modules_' . $slug . '_config';
|
|
}
|
|
}
|
|
$visibility[$module] = $this->hasAnyPermission($candidates);
|
|
}
|
|
}
|
|
} catch (Throwable) {
|
|
// Fail open for compatibility if module map cannot be loaded.
|
|
}
|
|
|
|
return $visibility;
|
|
}
|
|
|
|
private function hasAnyPermission(array $permissions): bool
|
|
{
|
|
foreach ($permissions as $permission) {
|
|
if (!is_string($permission) || $permission === '') {
|
|
continue;
|
|
}
|
|
if ($this->hasPermission($permission)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function flattenPermissionCatalog(array $permissions): array
|
|
{
|
|
$flat = [];
|
|
$walker = function (mixed $node) use (&$flat, &$walker): void {
|
|
if (!is_array($node)) {
|
|
return;
|
|
}
|
|
foreach ($node as $key => $value) {
|
|
if (is_string($key) && is_string($value)) {
|
|
$flat[$key] = $value;
|
|
continue;
|
|
}
|
|
if (is_array($value)) {
|
|
$walker($value);
|
|
}
|
|
}
|
|
};
|
|
$walker($permissions);
|
|
return $flat;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $user
|
|
* @return array<int, int>
|
|
*/
|
|
private function resolveAllowedDepartmentIds(mixed $user): array
|
|
{
|
|
if ($user === false) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
return $this->effectiveDepartmentIds($user);
|
|
} catch (Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function parseTypeList(mixed $value): array
|
|
{
|
|
$result = [];
|
|
$raw = [];
|
|
if (is_array($value)) {
|
|
$raw = $value;
|
|
} elseif (is_string($value)) {
|
|
$trimmed = trim($value);
|
|
if ($trimmed === '') {
|
|
return [];
|
|
}
|
|
if (str_starts_with($trimmed, '[')) {
|
|
$decoded = json_decode($trimmed, true);
|
|
if (is_array($decoded)) {
|
|
$raw = $decoded;
|
|
} else {
|
|
$raw = explode(',', $trimmed);
|
|
}
|
|
} else {
|
|
$raw = explode(',', $trimmed);
|
|
}
|
|
} elseif ($value !== null) {
|
|
$raw = [$value];
|
|
}
|
|
|
|
foreach ($raw as $item) {
|
|
if (!is_string($item)) {
|
|
continue;
|
|
}
|
|
$normalized = strtolower(trim($item));
|
|
if ($normalized === '') {
|
|
continue;
|
|
}
|
|
$result[] = $normalized;
|
|
}
|
|
return array_values(array_unique($result));
|
|
}
|
|
|
|
private function toBool(mixed $value, bool $default): bool
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
if (is_int($value) || is_float($value)) {
|
|
return (bool)$value;
|
|
}
|
|
if (is_string($value)) {
|
|
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
return $parsed ?? $default;
|
|
}
|
|
return $default;
|
|
}
|
|
|
|
private function clampInt(int $value, int $min, int $max, int $default): int
|
|
{
|
|
if ($value === 0) {
|
|
$value = $default;
|
|
}
|
|
if ($value < $min) {
|
|
return $min;
|
|
}
|
|
if ($value > $max) {
|
|
return $max;
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
private function clampMaxResults(int $value): int
|
|
{
|
|
return $this->clampInt($value, 1, 50, 50);
|
|
}
|
|
|
|
private function allEntityTypes(): array
|
|
{
|
|
return array_keys($this->entityPermissionMap());
|
|
}
|
|
|
|
/**
|
|
* Return all permission keys that can unlock at least one searchable entity type.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
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));
|
|
}
|
|
}
|