diff --git a/services/nginx/app/classes/authentication.php b/services/nginx/app/classes/authentication.php index 36ac415a..00660f23 100644 --- a/services/nginx/app/classes/authentication.php +++ b/services/nginx/app/classes/authentication.php @@ -181,4 +181,16 @@ class authentication implements authentication_i } return true; } + + public function get_subuser_customer_number_target(): int|false + { + /** + * Decode the headers + */ + $headers = getallheaders(); + if (!isset($headers['X-Customer-Number'])) { + return false; + } + return (int)$headers['X-Customer-Number']; + } } \ No newline at end of file diff --git a/services/nginx/app/cli.php b/services/nginx/app/cli.php index e6adefed..62b9d3c3 100644 --- a/services/nginx/app/cli.php +++ b/services/nginx/app/cli.php @@ -70,6 +70,14 @@ if ($args[1] === 'run') { echo "Running the economicOrderParser test script"; require_once 'tests/economicOrderParser/EconomicOrderParserTest.php'; break; + case 'subusers-grant-test': + echo "Running the Subusers grant initialization test script"; + require_once 'tests/subusers/SubuserUserGrantInitTest.php'; + break; + case 'permission-node-test': + echo "Running the Permission node mapping test script"; + require_once 'tests/permissions/PermissionNodeTest.php'; + break; case 'logSync': require_once 'cron/SyncLogs.php'; break; diff --git a/services/nginx/app/objects/subuser_grants_o.php b/services/nginx/app/objects/subuser_grants_o.php index 7e560e28..6278eb95 100644 --- a/services/nginx/app/objects/subuser_grants_o.php +++ b/services/nginx/app/objects/subuser_grants_o.php @@ -111,7 +111,7 @@ class subuser_grants_o extends db return $this; } - public function getGrantsForSubuserAndCustomer(int $subuser_id, int $customer_number): array + public function getGrantsForSubuserAndCustomer(int $subuser_id, ?int $customer_number): array { $grants = self::getFieldsWhere([ 'billing_customer_number' => $customer_number, diff --git a/services/nginx/app/objects/subusers_o.php b/services/nginx/app/objects/subusers_o.php index b002cef5..b399e69f 100644 --- a/services/nginx/app/objects/subusers_o.php +++ b/services/nginx/app/objects/subusers_o.php @@ -2,9 +2,12 @@ namespace objects; +use classes\authentication; use classes\db; use classes\object_property; use Exception; +use modules\subusers\classes\subuser_user_grant; +use modules\subusers\helpers\subusers_permission_node_key; use Random\RandomException; use traits\db_object_t; @@ -251,4 +254,26 @@ class subusers_o extends db $subuser->getObjectProperties(); return $subuser; } + + /** + * Check if the selected subuser has a permission related to a specific customer number. + * @param subusers_permission_node_key $permission_node_key The permission node key to check + * @param int|null $customer_number The customer number to check the permission for + * @return bool True if the subuser has the permission, false otherwise + * @throws Exception + */ + public function hasPermission(subusers_permission_node_key $permission_node_key, ?int $customer_number = null): bool + { + // Ensure a subuser is selected + self::requireSelected(); + // If the customer number is not provided, we should get it from the request context. + $customer_number = $customer_number ?? (new authentication())->get_subuser_customer_number_target(); + // If the customer number is still null, we cannot evaluate permissions, so we throw an exception. + if ($customer_number === null) { + throw new Exception('X-Customer-Number is required to evaluate permissions'); + } + // Evaluate permission using the subusers grant system + $grant = new subuser_user_grant((int)$this->id, (int)$customer_number); + return $grant->hasNode($permission_node_key); + } } \ No newline at end of file diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index 784d173a..49f3d2e9 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -152,8 +152,8 @@ class orderBookingRoute if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } - $permission_own = 'edit_own_bookings'; // Only permits editing bookings with the user's customer number - $permission_other = 'edit_bookings'; // Requires access to the department as an admin to edit other users' bookings + $permission_own = self::definePermission('edit_own_bookings', subusers_permission_node_key::BOOKINGS_EDIT); // Only permits editing bookings with the user's customer number (subusers node aware) + $permission_other = self::definePermission('edit_bookings'); // Requires access to the department as an admin to edit other users' bookings $has_permission_own = self::hasPermission($permission_own); $has_permission_other = self::hasPermission($permission_other); $has_permission = false; // Updated below @@ -224,8 +224,8 @@ class orderBookingRoute if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } - $permission_own = 'delete_own_bookings'; // Only permits deleting bookings with the user's customer number - $permission_other = 'delete_bookings'; // Requires access to the department as an admin to delete other users' bookings + $permission_own = self::definePermission('delete_own_bookings', subusers_permission_node_key::BOOKINGS_DELETE); // Only permits deleting bookings with the user's customer number (subusers node aware) + $permission_other = self::definePermission('delete_bookings'); // Requires access to the department as an admin to delete other users' bookings $has_permission_own = self::hasPermission($permission_own); $has_permission_other = self::hasPermission($permission_other); $has_permission = false; // Updated below @@ -373,6 +373,13 @@ class orderBookingRoute self::requireMaxValue($value, 999999999); $object = (new users_o())->getUserByCustomerNumber($value); if (!$object->exists()) $response->error($error, 400); + // If a subuser is making the request, expose the target customer number in meta + try { + $auth = new authentication(); + if ($auth->get_subuser() !== false) { + $response->add_meta('target_customer_number', $value); + } + } catch (\Throwable $e) { /* ignore meta set errors */ } return $object; } diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 2c13c035..30c4ca49 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -472,43 +472,37 @@ class subusersRoute $response->success($objects); }, []); - $this->get('/subusers/{id}', function () { + $this->get('/subusers/me', function () { global $response; - $user = (new authentication())->get_user(); - if ($user === false) { + $subuser = (new authentication())->get_subuser(); + if ($subuser === false) { $response->error('Unauthorized', 401); } - $customerNumber = (int)$user->customer_number->value(); - if ($customerNumber === 0) { - $response->error('Unauthorized', 401); - } - $id = (int)self::fromRoute('id'); - self::requireType($id, self::type_int()); - $subuser = (new subusers_o())->select($id); - if (!$subuser->exists()) { - $response->error('Subuser not found', 404); - } + $grants = (new subuser_grants_o())->getFieldsWhere([ + 'subuser' => $subuser->id, + 'enabled' => 1, + 'deleted_at' => null, + ], ['permissions', 'billing_customer_number']); - // Check visibility via grants - $permissions = (new subuser_grants_o())->getGrantsForSubuserAndCustomer($id, $customerNumber); - if (empty($permissions)) { - // Hide existence if not visible - $response->error('Subuser not found', 404); - } - - $response->success([ - 'id' => (int)$subuser->id, - 'username' => $subuser->username->value(), - 'name' => $subuser->name->value(), - 'email' => $subuser->email->value(), - 'phone_country_code' => (int)$subuser->phone_country_code->value(), - 'phone' => (int)$subuser->phone->value(), - 'created_at' => $subuser->created_at->value(), - 'updated_at' => $subuser->updated_at->value(), - 'suspended_at' => $subuser->suspended_at->value(), - 'permissions' => $permissions, - ]); + $result = [ + "id" => (int)$subuser->id, + "username" => $subuser->username->value(), + "name" => $subuser->name->value(), + "email" => $subuser->email->value(), + "phone_country_code" => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, + "phone" => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, + "grants" => array_map(function ($grant) { + return [ + 'billing_customer_number' => (int)$grant['billing_customer_number'], + 'permissions' => json_decode($grant['permissions'], true) ?: [], + ]; + }, $grants), + "created_at" => $subuser->created_at->value() ?? null, + "updated_at" => $subuser->updated_at->value() ?? null, + "suspended_at" => $subuser->suspended_at->value() ?? null, + ]; + $response->success($result); }, []); } } \ No newline at end of file diff --git a/services/nginx/app/tests/permissions/PermissionNodeTest.php b/services/nginx/app/tests/permissions/PermissionNodeTest.php new file mode 100644 index 00000000..b2801772 --- /dev/null +++ b/services/nginx/app/tests/permissions/PermissionNodeTest.php @@ -0,0 +1,47 @@ +definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST); + if (!($node instanceof permission_node)) { + fail('definePermission should return an instance of classes\\permission_node'); + } else { + ok('definePermission returns a permission_node'); + } + if ($node->permission !== 'list_own_bookings') { + fail('Permission string mismatch'); + } else { + ok('Permission string correctly set'); + } + if ($node->subusers_node_key !== subusers_permission_node_key::BOOKINGS_LIST) { + fail('Subusers node key mismatch'); + } else { + ok('Subusers node key correctly linked'); + } +} catch (Exception $e) { + fail('Exception thrown: ' . $e->getMessage()); +} + +echo "\nPermissionNodeTest completed.\n"; diff --git a/services/nginx/app/tests/subusers/SubuserUserGrantInitTest.php b/services/nginx/app/tests/subusers/SubuserUserGrantInitTest.php new file mode 100644 index 00000000..515d8050 --- /dev/null +++ b/services/nginx/app/tests/subusers/SubuserUserGrantInitTest.php @@ -0,0 +1,88 @@ +injectedPermissions = $injectedPermissions; + parent::__construct($subuser_id, $customer_number); + } + + // Override to inject permissions instead of querying DB + public function loadGrants(): void + { + // Manually enable nodes on each permission container without hitting the database + $reflection = new \ReflectionClass($this); + $properties = $reflection->getProperties(); + foreach ($properties as $property) { + $type = $property->getType(); + if ($type && is_a($type->getName(), \modules\subusers\classes\subusers_permission_nodes::class, true)) { + if (method_exists($property, 'isInitialized') && !$property->isInitialized($this)) { + continue; + } + $container = $property->getValue($this); + if ($container instanceof \modules\subusers\classes\subusers_permission_nodes) { + foreach ($this->injectedPermissions as $perm) { + $key = is_string($perm) ? $perm : $perm->name; + $node = $container->getNodeByKey($key); + if ($node) { + $node->value = true; + } + } + } + } + } + } +} + +// Scenario: subuser has only BOOKINGS_LIST granted => hasNode(BOOKINGS_LIST) = true, EDIT/DELETE = false +$grant = new TestSubuserGrant(9999, 42424242, [subusers_permission_node_key::BOOKINGS_LIST]); +if ($grant->hasNode(subusers_permission_node_key::BOOKINGS_LIST)) { + ok('BOOKINGS_LIST is granted as expected'); +} else { + fail('BOOKINGS_LIST should be granted but was not'); +} + +if (!$grant->hasNode(subusers_permission_node_key::BOOKINGS_EDIT)) { + ok('BOOKINGS_EDIT is not granted as expected'); +} else { + fail('BOOKINGS_EDIT should not be granted'); +} + +if (!$grant->hasNode(subusers_permission_node_key::BOOKINGS_DELETE)) { + ok('BOOKINGS_DELETE is not granted as expected'); +} else { + fail('BOOKINGS_DELETE should not be granted'); +} + +echo "\nSubuserUserGrantInitTest completed.\n"; diff --git a/services/nginx/app/traits/route_t.php b/services/nginx/app/traits/route_t.php index 1cde838c..6d03652e 100644 --- a/services/nginx/app/traits/route_t.php +++ b/services/nginx/app/traits/route_t.php @@ -15,6 +15,11 @@ trait route_t { protected array $permissions = []; private string $route; + /** + * Lightweight per-request caches to avoid repeated DB/auth checks during a single request lifecycle. + */ + protected static array $__perm_user_cache = []; + protected static array $__perm_subuser_grant_cache = []; public function __construct() { @@ -282,63 +287,102 @@ trait route_t } /** - * Require permission - * @param string|permission_node $permission - * @return bool + * Resolve customer number for a subuser permission context. + * Order of precedence: + * - Explicit `$customer_number` argument if provided + * - Main authenticated user context (if available) + * - Request header X-Customer-Number + * - Query/post parameter `customer_number` */ - public function requirePermission(string|permission_node $permission): bool + private function resolveCustomerNumberForSubuser(authentication $auth, ?int $customer_number = null): ?int + { + if ($customer_number !== null) { + return (int)$customer_number; + } + $user_ctx = $auth->get_user(); + if ($user_ctx !== false && isset($user_ctx->customer_number)) { + return (int)$user_ctx->customer_number->value(); + } + $headers = function_exists('getallheaders') ? getallheaders() : []; + if (isset($headers['X-Customer-Number'])) { + return (int)$headers['X-Customer-Number']; + } + if (isset($_GET['customer_number'])) { + return (int)$_GET['customer_number']; + } + if (isset($_POST['customer_number'])) { + return (int)$_POST['customer_number']; + } + return null; + } + + /** + * 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. + * - Supports explicit customer_number overrides and auto-detection fallback. + * - Uses simple per-request caches for efficiency. + */ + private function evaluatePermission(string|permission_node $permission, ?int $customer_number, bool $throwOnDeny): bool { global $response; - // Check if the users authorization token has the required permission try { - // If permission is a node and a subuser is authenticated, evaluate node-based grant first $auth = new authentication(); + + // If permission is a node and a subuser is authenticated, evaluate node-based grant first $subuser = $auth->get_subuser(); if ($subuser !== false && $permission instanceof permission_node && $permission->subusers_node_key !== null) { - // Determine customer number context - $customer_number = null; - $user_ctx = $auth->get_user(); - if ($user_ctx !== false && isset($user_ctx->customer_number)) { - $customer_number = (int)$user_ctx->customer_number->value(); - } - if ($customer_number === null) { - // Try to resolve from headers or request parameters as fallback - $headers = getallheaders(); - if (isset($headers['X-Customer-Number'])) { - $customer_number = (int)$headers['X-Customer-Number']; - } elseif (isset($_GET['customer_number'])) { - $customer_number = (int)$_GET['customer_number']; - } elseif (isset($_POST['customer_number'])) { - $customer_number = (int)$_POST['customer_number']; - } - } - if ($customer_number === null) { + $resolvedCustomer = $this->resolveCustomerNumberForSubuser($auth, $customer_number); + if ($resolvedCustomer === null) { (new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Missing customer context for subuser permission evaluation: ' . $permission->permission); - $response->error('Permission denied. Missing customer context for subuser.', 403); + if ($throwOnDeny) { + $response->error('Permission denied. Missing customer context for subuser.', 403); + } + return false; } - $grant = new subuser_user_grant((int)$subuser->id, (int)$customer_number); - if ($grant->hasNode($permission->subusers_node_key)) { - return true; + // Expose resolved target customer in response meta for subuser requests + $response->add_meta('target_customer_number', (int)$resolvedCustomer); + $subuser_has_permission = $subuser->hasPermission($permission->subusers_node_key); + 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); } - (new logs_o())->add('global', 'global', 1, $subuser->id ?? 0, 'PERMISSION_DENIED', 'Permission denied for subuser. Missing node: ' . $permission->subusers_node_key->name); - $response->error('Permission denied for subuser. Missing permission node: ' . $permission->subusers_node_key->name, 403); + return $subuser_has_permission; } // Fallback to classic user permission check (or if permission is plain string) $user = $auth->get_user(); if (!$user) { (new logs_o())->add('global', 'global', 1, 0, 'AUTHENTICATION_FAILED', 'Authentication failed. Invalid, or missing token'); - $response->error('Authentication failed. Invalid or missing token.', 401); + if ($throwOnDeny) { + $response->error('Authentication failed. Invalid or missing token.', 401); + } + return false; } $perm_string = $permission instanceof permission_node ? $permission->permission : $permission; - if (!$user->hasPermission($perm_string)) { + $userCacheKey = (string)$user->id . ':' . $perm_string; + if (!isset(self::$__perm_user_cache[$userCacheKey])) { + self::$__perm_user_cache[$userCacheKey] = (bool)$user->hasPermission($perm_string); + } + $allowed = (bool)self::$__perm_user_cache[$userCacheKey]; + 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); } + return $allowed; } catch (Exception $e) { $response->error($e->getMessage(), 400); + return false; } - return true; + } + + /** + * Require permission + * @param string|permission_node $permission + * @return bool + */ + public function requirePermission(string|permission_node $permission): bool + { + return $this->evaluatePermission($permission, null, true); } /** @@ -346,50 +390,9 @@ trait route_t * @param string|permission_node $permission * @return bool */ - public function hasPermission(string|permission_node $permission): bool + public function hasPermission(string|permission_node $permission, int $customer_number = null): bool { - global $response; - // Check if the users authorization token has the required permission - try { - $auth = new authentication(); - $subuser = $auth->get_subuser(); - if ($subuser !== false && $permission instanceof permission_node && $permission->subusers_node_key !== null) { - $customer_number = null; - $user_ctx = $auth->get_user(); - if ($user_ctx !== false && isset($user_ctx->customer_number)) { - $customer_number = (int)$user_ctx->customer_number->value(); - } - if ($customer_number === null) { - $headers = getallheaders(); - if (isset($headers['X-Customer-Number'])) { - $customer_number = (int)$headers['X-Customer-Number']; - } elseif (isset($_GET['customer_number'])) { - $customer_number = (int)$_GET['customer_number']; - } elseif (isset($_POST['customer_number'])) { - $customer_number = (int)$_POST['customer_number']; - } - } - if ($customer_number !== null) { - $grant = new subuser_user_grant((int)$subuser->id, (int)$customer_number); - if ($grant->hasNode($permission->subusers_node_key)) { - return true; - } - } - // IMPORTANT: When a subuser is authenticated and a subusers node key is defined - // the permission must ONLY pass if the explicit subuser node is granted. - // Do NOT fall back to classic user permissions in this branch. - return false; - } - $user = $auth->get_user(); - if (!$user) { - (new logs_o())->add('global', 'global', 1, 0, 'AUTHENTICATION_FAILED', 'Authentication failed. Invalid, or missing token'); - $response->error('Authentication failed. Invalid or missing token.', 401); - } - $perm_string = $permission instanceof permission_node ? $permission->permission : $permission; - return $user->hasPermission($perm_string); - } catch (Exception $e) { - $response->error($e->getMessage(), 400); - } + return $this->evaluatePermission($permission, $customer_number, false); } /**