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
437 lines
20 KiB
PHP
437 lines
20 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\response;
|
|
use classes\xlvask;
|
|
use objects\orders_o;
|
|
use objects\xlvask_usage_logs_o;
|
|
use traits\route_t;
|
|
|
|
use app\auth\Scope;
|
|
use app\auth\ScopeMiddleware;
|
|
|
|
class xlvaskUsageLogsRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
$this->get('/modules/xlvask/services/usage/orders', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/services/usage/orders');
|
|
$permission_list_own = 'list_xlvask_usage_orders_own';
|
|
$permission_list_all = 'list_xlvask_usage_orders_all';
|
|
$response_includes_items = false;
|
|
global $response;
|
|
if (!$this->hasPermission($permission_list_all)) {
|
|
$this->requirePermission($permission_list_own);
|
|
}
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
|
if ($allowedHallIds === []) {
|
|
$response->error('No XL Vask hall scope is available', 403);
|
|
}
|
|
$xlvask_usage_logs = new xlvask_usage_logs_o();
|
|
$xlvask = new xlvask();
|
|
$linked_order_ids_by_wash_id = [];
|
|
$xlvask_usage_log_class = $xlvask->helpers->xlvask_usage_log;
|
|
if (ini_get('memory_limit') < '5120M') {
|
|
ini_set('memory_limit', '5120M');
|
|
}
|
|
$result = $xlvask_usage_logs
|
|
->setAdditionalWhereClause(
|
|
"`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log_class::$default_customers) . "')"
|
|
)
|
|
->listObjectsWithPaginationIfSet(
|
|
function ($log) use (
|
|
$response_includes_items,
|
|
$xlvask_usage_logs,
|
|
$xlvask,
|
|
&$linked_order_ids_by_wash_id
|
|
) {
|
|
$id = (int)$log['id'];
|
|
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
|
|
unset($log['id']);
|
|
$log['WashItems'] = json_decode($log['WashItems'] ?? '[]', true);
|
|
$usage_log_payload = array_intersect_key($log, array_flip([
|
|
'WashId',
|
|
'CustomerId',
|
|
'Customer',
|
|
'VatNumber',
|
|
'Location',
|
|
'Hall',
|
|
'HallId',
|
|
'StartTime',
|
|
'FinishTime',
|
|
'RegistrationNumber',
|
|
'VehicleType',
|
|
'IdentificationType',
|
|
'IdentificationId',
|
|
'Info',
|
|
'Updated',
|
|
'Prepaid',
|
|
'FinishStatus',
|
|
'CustomerGuid',
|
|
'VehicleId',
|
|
'WashItems',
|
|
'ignored_at',
|
|
'ignored_by',
|
|
'ignored_reason',
|
|
]));
|
|
$tmp = $xlvask->new($xlvask_usage_log_class);
|
|
$tmp->setProperties($usage_log_payload);
|
|
$wash_id = (string)$tmp->WashId;
|
|
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
|
|
$linked_order = (new orders_o())->selectByWashId($wash_id);
|
|
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null
|
|
? (int)$linked_order->id
|
|
: null;
|
|
}
|
|
$linked_order_id = $wash_id !== ''
|
|
? $linked_order_ids_by_wash_id[$wash_id]
|
|
: null;
|
|
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
|
|
$tmp_res = $isEligibleForAutomaticContinuance
|
|
? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items)
|
|
: [];
|
|
$tmp_res['order']['customer_name'] = $tmp->Customer;
|
|
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
|
|
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
|
|
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
|
|
unset($tmp);
|
|
return [
|
|
'id' => $id,
|
|
'fast_link_key' => null,
|
|
'usage_log_id' => $id,
|
|
'linked_order_id' => $linked_order_id,
|
|
'ignored_at' => $log['ignored_at'] ?? null,
|
|
'ignored_by' => isset($log['ignored_by']) ? (int)$log['ignored_by'] : null,
|
|
'ignored_reason' => $log['ignored_reason'] ?? null,
|
|
...$tmp_res['order'],
|
|
];
|
|
},
|
|
$xlvask_usage_logs->forceRestrictFilters([
|
|
'HallId' => $allowedHallIds,
|
|
'FinishStatus' => ['1'],
|
|
])
|
|
);
|
|
$response->success($result);
|
|
}, [
|
|
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
|
|
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
|
|
]);
|
|
|
|
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/services/usage/orders/summary');
|
|
global $response;
|
|
if (!$this->hasPermission('list_xlvask_usage_orders_all')) {
|
|
$this->requirePermission('list_xlvask_usage_orders_own');
|
|
}
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
|
|
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
|
|
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
|
$summary = (new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(
|
|
$dateFrom,
|
|
$dateTo,
|
|
$allowedHallIds
|
|
);
|
|
$response->success(['summary' => $summary]);
|
|
}, [
|
|
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log summary',
|
|
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log summaries',
|
|
]);
|
|
|
|
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/xlvask/services/usage/orders/{id}/ignore');
|
|
global $response;
|
|
$this->requirePermission('review_xlvask_usage_order');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
$id = (int)($this->fromRoute('id') ?? 0);
|
|
if ($id < 1) {
|
|
$response->error('Invalid XL Vask usage log id', 400);
|
|
}
|
|
self::requireParameters(['reason']);
|
|
$reason = trim((string)$this->getParameter('reason'));
|
|
if (mb_strlen($reason) > 500) {
|
|
$response->error('Reason is too long (max 500 characters)', 400);
|
|
}
|
|
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
|
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
|
$log = new xlvask_usage_logs_o();
|
|
$log->getById($id);
|
|
if (!$log->id) {
|
|
$response->error('XL Vask usage log not found', 404);
|
|
}
|
|
$log->ignored_at->set(date('Y-m-d H:i:s'));
|
|
$log->ignored_by->set((int)$user->id);
|
|
$log->ignored_reason->set($reason);
|
|
$log->objectChanged();
|
|
$response->success([
|
|
'id' => $id,
|
|
'ignored_at' => $log->ignored_at->get(),
|
|
'ignored_by' => (int)$log->ignored_by->get(),
|
|
'ignored_reason' => $log->ignored_reason->get(),
|
|
]);
|
|
}, [
|
|
'review_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
|
|
]);
|
|
|
|
$this->post('/modules/xlvask/services/usage/orders/{id}/unignore', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/xlvask/services/usage/orders/{id}/unignore');
|
|
global $response;
|
|
$this->requirePermission('review_xlvask_usage_order');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
$id = (int)($this->fromRoute('id') ?? 0);
|
|
if ($id < 1) {
|
|
$response->error('Invalid XL Vask usage log id', 400);
|
|
}
|
|
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
|
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
|
$log = new xlvask_usage_logs_o();
|
|
$log->getById($id);
|
|
if (!$log->id) {
|
|
$response->error('XL Vask usage log not found', 404);
|
|
}
|
|
$log->ignored_at->set(null);
|
|
$log->ignored_by->set(null);
|
|
$log->ignored_reason->set(null);
|
|
$log->objectChanged();
|
|
$response->success(['id' => $id]);
|
|
}, [
|
|
'review_xlvask_usage_order' => 'Clear ignore metadata on an XL Vask usage log',
|
|
]);
|
|
|
|
$this->post('/modules/xlvask/services/usage/orders/{id}/accept', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/xlvask/services/usage/orders/{id}/accept');
|
|
global $response;
|
|
$this->requirePermission('review_xlvask_usage_order');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
$id = (int)($this->fromRoute('id') ?? 0);
|
|
if ($id < 1) {
|
|
$response->error('Invalid XL Vask usage log id', 400);
|
|
}
|
|
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
|
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
|
$log = new xlvask_usage_logs_o();
|
|
$log->getById($id);
|
|
if (!$log->id) {
|
|
$response->error('XL Vask usage log not found', 404);
|
|
}
|
|
$xlvask = new xlvask();
|
|
$log_helper = new ($xlvask->helpers->xlvask_usage_log)();
|
|
$log_helper->setProperties(array_intersect_key($log->toArray(), array_flip([
|
|
'WashId',
|
|
'CustomerId',
|
|
'Customer',
|
|
'VatNumber',
|
|
'Location',
|
|
'Hall',
|
|
'HallId',
|
|
'StartTime',
|
|
'FinishTime',
|
|
'RegistrationNumber',
|
|
'VehicleType',
|
|
'IdentificationType',
|
|
'IdentificationId',
|
|
'Info',
|
|
'Updated',
|
|
'Prepaid',
|
|
'FinishStatus',
|
|
'CustomerGuid',
|
|
'VehicleId',
|
|
'WashItems',
|
|
])));
|
|
$customer = $log_helper->getCustomer();
|
|
if ($customer === null) {
|
|
$response->error('XL Vask customer is not linkable', 422);
|
|
}
|
|
if (empty($customer->externId)) {
|
|
$response->error('XL Vask customer has no external id', 422);
|
|
}
|
|
$tmp_user = $customer->getUser();
|
|
if (!$tmp_user) {
|
|
$response->error('XL Vask customer is not provisioned in this system', 422);
|
|
}
|
|
try {
|
|
$order = $xlvask->getTasks()->createOrderFromWash($log_helper, $customer);
|
|
} catch (\Throwable $e) {
|
|
error_log('[xlvask-accept] createOrderFromWash failed: ' . $e->getMessage());
|
|
$response->error('Could not create order from XL Vask usage log: ' . $e->getMessage(), 422);
|
|
}
|
|
$log->ignored_at->set(date('Y-m-d H:i:s'));
|
|
$log->ignored_by->set((int)$user->id);
|
|
$log->ignored_reason->set('Accepted and converted to order ' . (int)$order->id);
|
|
$log->objectChanged();
|
|
$response->success([
|
|
'order_id' => (int)$order->id,
|
|
'usage_log_id' => $id,
|
|
]);
|
|
}, [
|
|
'review_xlvask_usage_order' => 'Convert an XL Vask usage log into an order',
|
|
]);
|
|
|
|
$this->post('/modules/xlvask/services/usage/orders/{id}/reject', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/xlvask/services/usage/orders/{id}/reject');
|
|
global $response;
|
|
$this->requirePermission('review_xlvask_usage_order');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
$id = (int)($this->fromRoute('id') ?? 0);
|
|
if ($id < 1) {
|
|
$response->error('Invalid XL Vask usage log id', 400);
|
|
}
|
|
self::requireParameters(['reason']);
|
|
$reason = trim((string)$this->getParameter('reason'));
|
|
if (mb_strlen($reason) > 500) {
|
|
$response->error('Reason is too long (max 500 characters)', 400);
|
|
}
|
|
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
|
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
|
$log = new xlvask_usage_logs_o();
|
|
$log->getById($id);
|
|
if (!$log->id) {
|
|
$response->error('XL Vask usage log not found', 404);
|
|
}
|
|
$log->ignored_at->set(date('Y-m-d H:i:s'));
|
|
$log->ignored_by->set((int)$user->id);
|
|
$log->ignored_reason->set('Rejected: ' . $reason);
|
|
$log->objectChanged();
|
|
$response->success([
|
|
'id' => $id,
|
|
'ignored_at' => $log->ignored_at->get(),
|
|
'ignored_by' => (int)$log->ignored_by->get(),
|
|
'ignored_reason' => $log->ignored_reason->get(),
|
|
]);
|
|
}, [
|
|
'review_xlvask_usage_order' => 'Reject an XL Vask usage log with a reviewer note',
|
|
]);
|
|
|
|
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
|
|
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/services/usage/orders/fast-link');
|
|
global $response;
|
|
$this->requirePermission('list_xlvask_usage_orders_own');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
self::requireParameters(['fast_link_key']);
|
|
$fast_link_key = (string)self::getParameter('fast_link_key');
|
|
self::requireType($fast_link_key, self::type_string());
|
|
self::requireMinLength('fast_link_key', 20);
|
|
self::requireMaxLength('fast_link_key', 50);
|
|
if (!preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
|
|
$response->error('Invalid fast link key format', 400);
|
|
}
|
|
$cached_data = redis->get($fast_link_key);
|
|
if (!$cached_data) {
|
|
$response->error('Fast link key not found', 404);
|
|
}
|
|
$data = json_decode($cached_data, true);
|
|
if (!is_array($data)) {
|
|
$response->error('Invalid cached data', 400);
|
|
}
|
|
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
|
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
|
|
$response->error('Fast link is outside the current XL Vask hall scope', 403);
|
|
}
|
|
redis->delete($fast_link_key);
|
|
$xlvask = new xlvask();
|
|
$order_arr = (new orders_o())->simulateOrderFromXLVask(
|
|
$xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data),
|
|
true
|
|
);
|
|
$tmp_order_obj = (object)[];
|
|
$tmp_order_arr = $order_arr['order'] ?? [];
|
|
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
|
|
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
|
|
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
|
|
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
|
|
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
|
|
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
|
|
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
|
|
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
|
|
$response->success([
|
|
...$order_arr,
|
|
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
|
|
$tmp_order_obj->reg_1,
|
|
$tmp_order_obj->reg_2,
|
|
$tmp_order_obj->reg_3,
|
|
$tmp_order_obj->department_id,
|
|
$tmp_order_obj->created_at,
|
|
),
|
|
]);
|
|
}, [
|
|
'fast_link_key' => 'string',
|
|
]);
|
|
}
|
|
|
|
private function allowedHallIdsForUser(object $user): array
|
|
{
|
|
global $db;
|
|
if ($this->hasPermission('list_xlvask_usage_orders_all')) {
|
|
$sqlWithSoftDelete = "SELECT DISTINCT HallId FROM plate_scanners
|
|
WHERE HallId IS NOT NULL AND TRIM(HallId) <> '' AND deleted_at IS NULL";
|
|
$sqlWithoutSoftDelete = "SELECT DISTINCT HallId FROM plate_scanners
|
|
WHERE HallId IS NOT NULL AND TRIM(HallId) <> ''";
|
|
try {
|
|
$result = $db->query($sqlWithSoftDelete);
|
|
} catch (\Throwable $throwable) {
|
|
$message = (string)$throwable->getMessage();
|
|
$missingDeletedAt = str_contains($message, "Unknown column 'deleted_at'")
|
|
|| str_contains($message, "Unknown column `deleted_at`");
|
|
if (!$missingDeletedAt) {
|
|
throw $throwable;
|
|
}
|
|
$result = $db->query($sqlWithoutSoftDelete);
|
|
}
|
|
$hallIds = $result === false ? [] : array_map(
|
|
static fn(array $row): string => trim((string)($row['HallId'] ?? '')),
|
|
$db->fetch_all($result)
|
|
);
|
|
} else {
|
|
$hallIds = (array)$user->getGroup()->getDepartmentsScannersHallIds();
|
|
}
|
|
return array_values(array_unique(array_filter(
|
|
array_map(static fn(mixed $id): string => trim((string)$id), $hallIds),
|
|
static fn(string $id): bool => $id !== '' && strlen($id) <= 191
|
|
)));
|
|
}
|
|
|
|
private static function requireUsageLogInHallScope(int $usageLogId, array $allowedHallIds): void
|
|
{
|
|
global $db, $response;
|
|
if ($allowedHallIds === []) {
|
|
$response->error('XL Vask usage log not found', 404);
|
|
}
|
|
$hallSql = implode(',', array_map(
|
|
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
|
|
$allowedHallIds
|
|
));
|
|
$result = $db->query(
|
|
"SELECT id FROM xlvask_usage_logs WHERE id = {$usageLogId} AND HallId IN ({$hallSql}) LIMIT 1"
|
|
);
|
|
if ($result === false || $result->num_rows < 1) {
|
|
$response->error('XL Vask usage log not found', 404);
|
|
}
|
|
}
|
|
}
|