Add subuser permission evaluation system and extend subuser-related route handling

- Introduce `hasPermission` method in `subusers_o` for permission checks tied to customer context.
- Update `/subusers/me` route to return subuser grants with normalized permissions and metadata.
- Add `get_subuser_customer_number_target` in `authentication` to resolve customer context from request headers.
- Refactor route-level permission checks to handle subuser grants dynamically.
- Introduce CLI test scripts for subuser grants and permission node mappings.
- Add test coverage for subuser grants and permission nodes in new test classes.
This commit is contained in:
Jeppe Bundgaard
2026-02-12 15:29:43 +01:00
parent 6375bc7f12
commit 497ef1496b
9 changed files with 297 additions and 113 deletions
+79 -76
View File
@@ -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);
}
/**