Refactor subuser permissions and enhance artifact management

This commit is contained in:
Jeppe Bundgaard
2026-07-01 13:18:27 +02:00
parent 11d39af934
commit 866a5be126
19 changed files with 756 additions and 167 deletions
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ use Exception;
class edge_gateway_agent_artifact_locator
{
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
@@ -28,6 +29,10 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::normalizePath($configuredDirectory);
}
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
$candidateDirectories[] = $directory;
}
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
}
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
$candidateDirectories[] = $directory;
}
$paths = [];
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
}
/**
* @return array<int,string>
*/
private static function edgeAgentBuildDirectories(string $basePath): array
{
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories = [
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
];
if (DIRECTORY_SEPARATOR === '/') {
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
}
return array_values(array_unique(array_map(
static fn(string $directory): string => self::normalizePath($directory),
$directories
)));
}
/**
* @return array<int,string>
*/
private static function legacyPhpAgentDirectories(string $basePath): array
{
return [
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
];
}
private static function mountedArtifactDirectory(): ?string
{
if (DIRECTORY_SEPARATOR !== '/') {
@@ -7,6 +7,7 @@ use Exception;
class edge_gateway_install_service
{
private const ARTIFACTS = [
'manifest.json' => 'application/json; charset=utf-8',
'agent.php' => 'application/x-httpd-php; charset=utf-8',
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
@@ -42,6 +43,10 @@ class edge_gateway_install_service
*/
public function readArtifact(string $fileName): string
{
if ($fileName === 'manifest.json') {
return $this->buildManifest();
}
$path = $this->artifactPath($fileName);
$contents = file_get_contents($path);
if ($contents === false) {
@@ -66,13 +71,48 @@ class edge_gateway_install_service
*/
public function artifactPath(string $fileName): string
{
if (!array_key_exists($fileName, self::ARTIFACTS)) {
if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
throw new Exception('Unknown edge agent artifact');
}
return edge_gateway_agent_artifact_locator::resolve($fileName);
}
/**
* @throws Exception
*/
private function buildManifest(): string
{
$artifacts = [];
foreach (self::ARTIFACTS as $fileName => $contentType) {
if ($fileName === 'manifest.json') {
continue;
}
$path = $this->artifactPath($fileName);
$sha256 = hash_file('sha256', $path);
$bytes = filesize($path);
if ($sha256 === false || $bytes === false) {
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
}
$artifacts[] = [
'name' => $fileName,
'sha256' => $sha256,
'bytes' => $bytes,
'content_type' => $contentType,
];
}
return json_encode([
'schema_version' => 1,
'package' => 'truckwash-edge-agent',
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
'generated_at' => gmdate('c'),
'artifacts' => $artifacts,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
@@ -33,6 +33,7 @@ class edge_gateway_manager
public const STATUS_DEGRADED = 'DEGRADED';
public const STATUS_OFFLINE = 'OFFLINE';
public const DEFAULT_RELEASE_CHANNEL = 'stable';
public const DEFAULT_INSTALL_VERSION = 'compose-php-agent-v3';
public const DEFAULT_AGENT_SERVICE_NAME = 'truckwash-edge-agent.service';
public const DEFAULT_STACK_SERVICE_NAME = 'truckwash-edge-gateway-stack.service';
public const DEFAULT_COMPOSE_STACK_FILE = 'docker-compose.gateway.yml';
@@ -2309,6 +2310,7 @@ class edge_gateway_manager
'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
'heartbeatIntervalSeconds' => 15,
'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
'installedVersion' => self::DEFAULT_INSTALL_VERSION,
], JSON_UNESCAPED_SLASHES);
$script = <<<'BASH'
@@ -2543,6 +2545,42 @@ fetch_http() {
[ "$cleanup_body" -eq 1 ] && rm -f "$body_path"
rm -f "$headers_path"
}
verify_manifest_artifact() {
local manifest_path="$1"
local artifact_name="$2"
local artifact_path="$3"
log_info "Verifying ${artifact_name} checksum"
php -r '
$manifestPath = $argv[1];
$artifactName = $argv[2];
$artifactPath = $argv[3];
$manifest = json_decode((string)file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
fwrite(STDERR, "Invalid artifact manifest: " . $manifestPath . PHP_EOL);
exit(1);
}
$expected = null;
foreach ((array)($manifest["artifacts"] ?? []) as $artifact) {
if (is_array($artifact) && ($artifact["name"] ?? null) === $artifactName) {
$expected = (string)($artifact["sha256"] ?? "");
break;
}
}
if ($expected === null || $expected === "") {
fwrite(STDERR, "Artifact missing from manifest: " . $artifactName . PHP_EOL);
exit(1);
}
if (!is_file($artifactPath)) {
fwrite(STDERR, "Downloaded artifact is missing: " . $artifactPath . PHP_EOL);
exit(1);
}
$actual = hash_file("sha256", $artifactPath);
if ($actual === false || !hash_equals($expected, $actual)) {
fwrite(STDERR, "Artifact checksum mismatch for " . $artifactName . PHP_EOL);
exit(1);
}
' "$manifest_path" "$artifact_name" "$artifact_path"
}
cleanup_existing_installation() {
if [ "$INSTALL_DIR" != "/opt/truckwash-edge-agent" ]; then
echo "Refusing to remove unexpected install directory: $INSTALL_DIR" >&2
@@ -2707,6 +2745,7 @@ run_step "Updating package lists" apt-get update
run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3
run_step "Installing Docker Compose runtime" install_compose_runtime
begin_install_phase "DOWNLOAD_ARTIFACTS" "Downloading edge gateway artifacts"
fetch_http "Download artifact manifest" "__MANIFEST_URL__" "$INSTALL_DIR/manifest.json"
fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"
fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"
fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"
@@ -2717,6 +2756,17 @@ fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__"
fetch_http "Download gateway launcher" "__LAUNCHER_URL__" "$INSTALL_DIR/gateway-launcher.sh"
fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"
fetch_http "Download compatibility service unit" "__LEGACY_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
run_step "Verifying PHP edge agent" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"
run_step "Verifying LAN worker" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "lan-worker.php" "$INSTALL_DIR/lan-worker.php"
run_step "Verifying auto-updater" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "auto-updater.php" "$INSTALL_DIR/auto-updater.php"
run_step "Verifying compose stack" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"
run_step "Verifying edge-agent Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.edge-agent" "$INSTALL_DIR/Dockerfile.edge-agent"
run_step "Verifying lan-worker Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.lan-worker" "$INSTALL_DIR/Dockerfile.lan-worker"
run_step "Verifying auto-updater Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.auto-updater" "$INSTALL_DIR/Dockerfile.auto-updater"
run_step "Verifying gateway launcher" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "gateway-launcher.sh" "$INSTALL_DIR/gateway-launcher.sh"
run_step "Verifying compose stack service unit" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"
run_step "Verifying compatibility service unit" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"
begin_install_phase "WRITE_CONFIG" "Writing gateway configuration"
cat > "$CONFIG_TEMPLATE_PATH" <<'EOF_JSON'
__CONFIG_JSON__
@@ -2745,6 +2795,7 @@ BASH;
'__INSTALL_TOKEN__' => $plainToken,
'__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken),
'__STATUS_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/status',
'__MANIFEST_URL__' => $this->buildAgentArtifactUrl('manifest.json'),
'__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'),
'__WORKER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
'__AUTO_UPDATER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT),
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
@@ -23,11 +23,11 @@ class subuser_grants_o extends db
public object_property $deleted_at;
const defaultPermissions = [
'VEHICLES_LIST',
'SELFSERVE_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
'ORDERS_LIST',
];
public static function normalizePermissionsValue(mixed $raw): array
+19 -3
View File
@@ -171,6 +171,7 @@ class subusers_o extends db
return $this;
} catch (Exception $e) {
$response->error($e->getMessage());
throw $e;
}
}
@@ -217,9 +218,16 @@ class subusers_o extends db
throw new RandomException('Error generating random bytes for setup token', 0, $e);
}
$cache_key = 'setup_token:' . $token;
$cashe_object_id = 'subuser_setup_token';
$this->cache($cache_key, $this->id, $cashe_object_id);
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cashe_object_id); // Set the cache expiration to 24 hours
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
$cache_object_id = 'subuser_setup_token';
$existingToken = $this->getCached($reverse_cache_key, $cache_object_id);
if (is_string($existingToken) && $existingToken !== '') {
$this->deleteCached('setup_token:' . $existingToken, $cache_object_id);
}
$this->cache($cache_key, $this->id, $cache_object_id);
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cache_object_id); // Set the cache expiration to 24 hours
$this->cache($reverse_cache_key, $token, $cache_object_id);
$this->setCachedExpiration($reverse_cache_key, 24 * 60 * 60, $cache_object_id);
return $token;
}
@@ -260,8 +268,16 @@ class subusers_o extends db
public function invalidateSetupToken(string $token): void
{
$object_id = 'subuser_setup_token';
$subuser_id = $this->getSubuserIdBySetupToken($token);
$cache_key = 'setup_token:' . $token;
$this->deleteCached($cache_key, $object_id);
if ($subuser_id !== null) {
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$subuser_id;
$currentToken = $this->getCached($reverse_cache_key, $object_id);
if ((string)$currentToken === $token) {
$this->deleteCached($reverse_cache_key, $object_id);
}
}
}
/**
@@ -1108,7 +1108,7 @@ final class TruckwashEdgeAgent
return;
}
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v3');
try {
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
@@ -1264,10 +1264,10 @@ final class TruckwashEdgeAgent
'agent_token' => (string)$this->config->get('agentToken'),
'status' => 'ONLINE',
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
'target_version' => (string)$this->config->get(
'targetVersion',
$this->config->get('installedVersion', 'compose-php-agent-v2')
$this->config->get('installedVersion', 'compose-php-agent-v3')
),
'metadata' => array_merge([
'agent_instance_id' => $this->agentInstanceId,
@@ -1964,7 +1964,7 @@ final class TruckwashEdgeAgent
return [
'applied' => false,
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
'staged_version' => $targetVersion,
'target_version' => $targetVersion,
'staged_at' => $stagedAt,
+125 -28
View File
@@ -24,6 +24,72 @@ class authRoute
{
use route_t;
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
{
return 'passkey_challenge_principal:' . $challengeToken;
}
private function setPasskeyChallengePrincipal(string $challengeToken, string $principalType): void
{
if (!defined('redis')) {
return;
}
constant('redis')->setEx($this->passkeyChallengePrincipalCacheKey($challengeToken), $principalType, 5 * 60);
}
private function getPasskeyChallengePrincipal(string $challengeToken): string
{
if (!defined('redis')) {
return 'discoverable';
}
$principalType = constant('redis')->get($this->passkeyChallengePrincipalCacheKey($challengeToken));
return is_string($principalType) && in_array($principalType, ['user', 'subuser'], true)
? $principalType
: 'discoverable';
}
private function clearPasskeyChallengePrincipal(string $challengeToken): void
{
if (!defined('redis')) {
return;
}
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
}
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
{
if ($userId <= 0) {
return [];
}
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause(
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0')
);
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
if (isset($list['items']) && is_array($list['items'])) {
$list = $list['items'];
}
return is_array($list)
? array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}))
: [];
}
public function run(): void
{
$this->post('/auth/login', function () {
@@ -625,11 +691,53 @@ class authRoute
global $response;
$this->requireRecaptcha();
$principal_type = strtolower(trim((string)(self::getParameter('principal_type') ?? self::getParameter('user_type') ?? '')));
if ($principal_type === '') {
$principal_type = self::getParameter('customer_number') !== null ? 'user' : 'discoverable';
}
if (!in_array($principal_type, ['user', 'subuser', 'discoverable'], true)) {
$response->error('Invalid principal_type', 400);
}
$customer_number = self::getParameter('customer_number');
$user_id = 0;
$allowCredentials = [];
if ($customer_number !== null) {
if ($principal_type === 'subuser') {
$subuser = null;
if (self::isParametersSet(['subuser_id'])) {
$subuser_id = (int)self::getParameter('subuser_id');
self::requireType($subuser_id, $this->type_int());
self::requireMinValue($subuser_id, 1);
$candidate = (new subusers_o())->select($subuser_id);
if ($candidate->exists()) {
$candidate->getObjectProperties();
$subuser = $candidate;
}
} elseif (self::isParametersSet(['username'])) {
$username = (string)self::getParameter('username');
self::requireType($username, $this->type_string());
self::requireMinLength('username', 3);
self::requireMaxLength('username', 255);
$subuser = (new subusers_o())->getSubuserByUsername($username);
} elseif (self::isParametersSet(['phone_country_code', 'phone'])) {
$phone_country_code = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
self::requireType($phone_country_code, $this->type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireType($phone, $this->type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
}
if ($subuser !== null) {
$user_id = (int)$subuser->id;
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
}
} elseif ($customer_number !== null) {
$principal_type = 'user';
$customer_number = (int)$customer_number;
self::requireType($customer_number, $this->type_int());
self::requireMinValue($customer_number, 1);
@@ -639,33 +747,7 @@ class authRoute
if ($user->exists()) {
$user_id = (int)$user->id;
// Load passkeys for this user (non-subuser)
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
// Ensure we return a simple array of credentials (without pagination wrapper)
if (isset($list['items']) && is_array($list['items'])) {
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
} elseif (is_array($list)) {
$allowCredentials = array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
}
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
}
}
@@ -682,6 +764,10 @@ class authRoute
// Create an ephemeral token to bind the challenge to the (potential) user
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
$this->setPasskeyChallengePrincipal(
$challenge_token,
$principal_type === 'subuser' ? 'subuser' : ($principal_type === 'user' ? 'user' : 'discoverable')
);
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
@@ -733,6 +819,7 @@ class authRoute
if ($token_type !== 'PASSKEY_CHALLENGE') {
$response->error('Invalid token type', 401);
}
$challengePrincipalType = $this->getPasskeyChallengePrincipal($challenge_token);
// Determine rpId/host
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
@@ -768,7 +855,17 @@ class authRoute
// Success → issue session token accordingly and delete the challenge token
$issued_to_user_id = (int)$passkey->user_id->value();
$is_subuser = (bool)$passkey->is_subuser->value();
if (
($challengePrincipalType === 'subuser' && !$is_subuser)
|| ($challengePrincipalType === 'user' && $is_subuser)
) {
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
$token_o->delete($challenge_token);
$this->clearPasskeyChallengePrincipal($challenge_token);
$response->error('Invalid credential', 401);
}
$token_o->delete($challenge_token);
$this->clearPasskeyChallengePrincipal($challenge_token);
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
+55 -35
View File
@@ -11,21 +11,48 @@ class passkeysRoute
{
use route_t;
/**
* @return array{principal:object,user_id:int,is_subuser:bool}
*/
private function resolvePasskeyPrincipal(string $classicUserPermission): array
{
global $response;
$auth = new authentication();
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [
'principal' => $subuser,
'user_id' => (int)$subuser->id,
'is_subuser' => true,
];
}
self::requirePermission($classicUserPermission);
$user = $auth->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_AUTH', 'User not logged in');
$response->error('Invalid session', 400);
}
return [
'principal' => $user,
'user_id' => (int)$user->id,
'is_subuser' => false,
];
}
public function run(): void
{
// List passkeys for current authenticated user
$this->get('/account/security/passkeys', function () {
global $response;
self::requirePermission('user_security_passkeys_list');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_LIST', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_list');
$passkeys = new passkeys_o();
// Restrict to current user (customer) and non-subuser records
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user->id . ' AND `is_subuser` = 0');
$passkeys->setAdditionalWhereClause(
'`user_id` = ' . (int)$principal['user_id'] . ' AND `is_subuser` = ' . ($principal['is_subuser'] ? '1' : '0')
);
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
// $o is an associative array from the database
$transports = null;
@@ -45,7 +72,7 @@ class passkeysRoute
];
});
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
$response->success($list);
}, [
'user_security_passkeys_list' => 'List passkeys for the authenticated user',
@@ -54,12 +81,7 @@ class passkeysRoute
// Create/add a passkey (store after client-side WebAuthn attestation)
$this->post('/account/security/passkeys', function () {
global $response;
self::requirePermission('user_security_passkeys_create');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_CREATE', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_create');
self::requireParameters(['credential_id', 'public_key', 'algorithm', 'transports']);
@@ -100,9 +122,9 @@ class passkeysRoute
}
$obj = new passkeys_o();
$obj->add((int)$user->id, false, $credential_id, $public_key, $algorithm, (array)$transports, $name);
$obj->add((int)$principal['user_id'], (bool)$principal['is_subuser'], $credential_id, $public_key, $algorithm, (array)$transports, $name);
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
$response->success(['id' => $obj->id]);
}, [
'user_security_passkeys_create' => 'Create/add a new passkey for the authenticated user',
@@ -111,12 +133,7 @@ class passkeysRoute
// Rename a passkey
$this->patch('/account/security/passkeys/{id}', function () {
global $response;
self::requirePermission('user_security_passkeys_rename');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_RENAME', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_rename');
$id = (int)self::fromRoute('id');
self::requireParameterIntPositive($id, 'id');
@@ -127,13 +144,17 @@ class passkeysRoute
self::requireMaxLength('name', 255);
$obj = (new passkeys_o())->select($id);
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
if (
!$obj->exists()
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
) {
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
$response->error('Not found', 404);
}
$obj->update(['name' => $name]);
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
$response->success(['message' => 'Renamed', 'id' => $id]);
}, [
'user_security_passkeys_rename' => 'Rename a passkey that belongs to the authenticated user',
@@ -142,24 +163,23 @@ class passkeysRoute
// Delete a passkey (soft delete)
$this->delete('/account/security/passkeys/{id}', function () {
global $response;
self::requirePermission('user_security_passkeys_delete');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_DELETE', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_delete');
$id = (int)self::fromRoute('id');
self::requireParameterIntPositive($id, 'id');
$obj = (new passkeys_o())->select($id);
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
if (
!$obj->exists()
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
) {
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
$response->error('Not found', 404);
}
$obj->delete();
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
$response->success(['message' => 'Deleted', 'id' => $id]);
}, [
'user_security_passkeys_delete' => 'Delete a passkey that belongs to the authenticated user',
+110 -78
View File
@@ -231,7 +231,57 @@ class subusersRoute
private function buildSetupLink(string $token): string
{
return 'https://truckwash.io/complete-registration?token=' . $token;
$frontendBaseUrl = trim((string)(
getenv('FRONTEND_URL')
?: getenv('APP_URL')
?: ($_SERVER['FRONTEND_URL'] ?? '')
?: ($_SERVER['APP_URL'] ?? '')
?: 'https://truckwash.io'
));
$frontendBaseUrl = rtrim($frontendBaseUrl !== '' ? $frontendBaseUrl : 'https://truckwash.io', '/');
return $frontendBaseUrl . '/complete-registration?token=' . rawurlencode($token);
}
private function clientThrottleIp(): string
{
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
return $remoteAddress !== '' ? $remoteAddress : 'unknown';
}
private function recordThrottleAttempt(string $scope, string $identifier, int $limit, int $windowSeconds): ?string
{
global $response;
if (!defined('redis')) {
return null;
}
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
'sha256',
$this->clientThrottleIp() . ':' . $identifier
);
$redis = constant('redis');
$attempts = (int)($redis->get($key) ?? '0');
if ($attempts >= $limit) {
$response->error('Too many attempts. Please wait and try again.', 429);
}
$redis->setEx($key, (string)($attempts + 1), $windowSeconds);
return $key;
}
private function clearThrottleAttempt(?string $key): void
{
if ($key === null || !defined('redis')) {
return;
}
constant('redis')->delete($key);
}
private function subuserAuthFailure(): void
{
global $response;
$response->error('Invalid credentials', 401);
}
private function issueSetupInvite(subusers_o $subuser): array
@@ -272,7 +322,7 @@ class subusersRoute
$delivery = [
'channel' => 'sms',
'status' => 'failed',
'message' => $exception->getMessage(),
'message' => 'Invite delivery failed.',
];
}
@@ -330,7 +380,7 @@ class subusersRoute
'subuser' => $subuser->id,
'enabled' => 1,
'deleted_at' => null,
], ['permissions', 'billing_customer_number']);
], ['id', 'permissions', 'billing_customer_number']);
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
$grants
@@ -346,6 +396,7 @@ class subusersRoute
'grants' => array_map(function ($grant) use ($customerNames) {
$customerNumber = (int)$grant['billing_customer_number'];
return [
'grant_id' => isset($grant['id']) ? (int)$grant['id'] : null,
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
'billing_customer_number' => $customerNumber,
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
@@ -762,28 +813,9 @@ class subusersRoute
self::requireType($note, self::type_string());
self::requireMaxLength('note', 65535);
}
$permissions = null;
if (self::isParametersSet(['permissions'])) {
$raw = self::getParameter('permissions');
// Expect array (already parsed) or JSON string
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$response->error('Invalid permissions payload', 400);
}
$permissions = $decoded;
} elseif (is_array($raw)) {
$permissions = $raw;
} else {
$response->error('Invalid permissions type', 400);
}
// Validate each permission is a known key
foreach ($permissions as $perm) {
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
$response->error('Unknown permission key: ' . (string)$perm, 400);
}
}
}
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
$response->success(['grant' => $grant->asArray()]);
@@ -834,25 +866,7 @@ class subusersRoute
$grant->note->set($note);
}
if (self::isParametersSet(['permissions'])) {
$raw = self::getParameter('permissions');
// Expect array (already parsed) or JSON string
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$response->error('Invalid permissions payload', 400);
}
$permissions = $decoded;
} elseif (is_array($raw)) {
$permissions = $raw;
} else {
$response->error('Invalid permissions type', 400);
}
// Validate each permission is a known key
foreach ($permissions as $perm) {
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
$response->error('Unknown permission key: ' . (string)$perm, 400);
}
}
$permissions = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
$grant->permissions->set($permissions);
}
$response->success($grant->asArray());
@@ -866,6 +880,14 @@ class subusersRoute
$this->get('/subusers/permission-nodes', function () {
global $response;
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|| self::hasPermission('list_subusers')
|| self::hasPermission('add_subusers')
|| self::hasPermission('edit_subusers');
if (!$canUseGlobalManagement) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
}
// Build groups
$groups = [
new subusers_permission_nodes_bookings(),
@@ -893,7 +915,13 @@ class subusersRoute
];
}
$response->success($out);
}, []);
}, [
'list_own_subusers' => 'List chauffeur permission nodes for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
'manage_subuser_grants' => 'List chauffeur permission nodes for administrative grant management.',
'list_subusers' => 'List chauffeur permission nodes for superuser management.',
'add_subusers' => 'List chauffeur permission nodes while inviting chauffeurs.',
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
]);
$this->post('/subusers', function () {
global /** @var response $response */
@@ -948,15 +976,7 @@ class subusersRoute
(int)$phone_country_code,
(int)$phone
);
// Send an SMS with a link to complete the registration process.
$gatewayAPI = new gatewayapi();
if ($gatewayAPI->isEnabled()) {
$token = $subuser->generateSetupToken();
$link = 'https://truckwash.io/complete-registration?token=' . $token;
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
$phone_number_array = [(string)$phone_country_code . (string)$phone];
$gatewayAPI->send($phone_number_array, $message);
}
$invite = $this->issueSetupInvite($subuser);
// Add the grant request
$subuser_grants_o = new subuser_grants_o();
try {
@@ -964,7 +984,7 @@ class subusersRoute
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
}
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
// Code for creating a new subuser would go here
});
$this->get('/subusers/setup', function () {
@@ -972,11 +992,13 @@ class subusersRoute
global $response;
self::requireParameters(['token']);
$token = self::getParameter('token');
$setupThrottleKey = $this->recordThrottleAttempt('setup_token', 'token-validation', 20, 15 * 60);
// Get the subuser with the setup token
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
if ($subuser === null) {
$response->error('Invalid or expired token', 400);
}
$this->clearThrottleAttempt($setupThrottleKey);
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
});
$this->post('/subusers/setup', function () {
@@ -986,6 +1008,7 @@ class subusersRoute
$token = (string)self::getParameter('token');
$password = (string)self::getParameter('password');
$name = (string)self::getParameter('name');
$setupThrottleKey = $this->recordThrottleAttempt('setup_complete', 'token-complete', 20, 15 * 60);
$this->requireSubuserPasswordPolicy($password);
self::requireType($name, self::type_string());
self::requireMinLength('name', 3);
@@ -1017,18 +1040,24 @@ class subusersRoute
if ($subuser === null) {
$response->error('Invalid or expired token', 400);
}
$this->assertSubuserIdentifiersAvailable(
null,
null,
$username,
$email,
(int)$subuser->id
);
// Set the password for the subuser
try {
$subuser->setPassword($password);
if (!empty($username) || !empty($email) || !empty($name)) {
$subuser->update([
...(!empty($username) ? ['username' => $username] : []),
...(!empty($email) ? ['email' => $email] : []),
...(!empty($name) ? ['name' => $name] : []),
]);
}
$subuser->update([
'password' => password_hash($password, PASSWORD_DEFAULT),
'name' => $name,
...(!empty($username) ? ['username' => $username] : []),
...(!empty($email) ? ['email' => $email] : []),
]);
// Invalidate the setup token
$subuser->invalidateSetupToken($token);
$this->clearThrottleAttempt($setupThrottleKey);
$response->success(['message' => 'Complete registration successful']);
} catch (Exception $e) {
$response->error($e->getMessage(), 400);
@@ -1067,23 +1096,34 @@ class subusersRoute
} else {
$response->error('You must provide either phone_country_code & phone, subuser_id or username', 400);
}
$identifier = $username !== null
? 'username:' . strtolower($username)
: ($subuser_id !== null
? 'id:' . (string)$subuser_id
: 'phone:' . (string)$phone_country_code . ':' . (string)$phone);
$authThrottleKey = $this->recordThrottleAttempt('auth_password', $identifier, 10, 15 * 60);
// Get the subuser based on the provided username type
$subuser = null;
if ($phone_country_code !== null && $phone !== null) {
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
} elseif ($subuser_id !== null) {
$subuser = (new subusers_o())->select($subuser_id);
$candidate = (new subusers_o())->select($subuser_id);
if ($candidate->exists()) {
$candidate->getObjectProperties();
$subuser = $candidate;
}
} elseif ($username !== null) {
$subuser = (new subusers_o())->getSubuserByUsername($username);
}
if ($subuser === null) {
$response->error('Subuser not found', 404);
$this->subuserAuthFailure();
}
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
$this->requireSubuserPasswordPolicy($password);
try {
if (password_verify($password, $subuser->password->value())) {
$passwordHash = $subuser->password->value();
if (is_string($passwordHash) && $passwordHash !== '' && password_verify($password, $passwordHash)) {
$this->clearThrottleAttempt($authThrottleKey);
if ($subuser->isTwoFactorEnabled()) {
$token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER');
$response->success(['2fa_required' => true, '2fa_token' => $token]);
@@ -1092,7 +1132,7 @@ class subusersRoute
$session = $subuser->generateSession();
$response->success(['session' => $session]);
} else {
$response->error('Invalid password', 400);
$this->subuserAuthFailure();
}
} catch (Exception $e) {
$response->error($e->getMessage(), 500);
@@ -1363,15 +1403,7 @@ class subusersRoute
(int)$phone
);
// Optionally send SMS with setup link
$gatewayAPI = new gatewayapi();
if ($gatewayAPI->isEnabled()) {
$token = $subuser->generateSetupToken();
$link = 'https://truckwash.io/complete-registration?token=' . $token;
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
$phone_number_array = [(string)$phone_country_code . (string)$phone];
$gatewayAPI->send($phone_number_array, $message);
}
$invite = $this->issueSetupInvite($subuser);
// Create a pending grant request for the company
$subuser_grants_o = new subuser_grants_o();
@@ -1381,7 +1413,7 @@ class subusersRoute
$response->error('Failed to add subuser grant', 500);
}
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
});
}
}
@@ -200,6 +200,67 @@ it('logs out and invalidates cached subuser sessions', function (): void {
->assertMessage('Unauthorized');
});
it('scopes passkey challenges to the requested principal type', function (): void {
api_test_covers('POST /auth/passkey/challenge', 'auth');
api_fixtures()->setModuleConfig('reCAPTCHA', 'enabled', 'false');
$user = api_fixtures()->createUser([
'display_name' => 'Passkey Customer',
]);
$subuser = api_fixtures()->createSubuser([
'username' => 'passkey-driver',
]);
api_fixtures()->createPasskey([
'user_id' => (int)$user['id'],
'is_subuser' => false,
'credential_id' => 'customer-passkey-credential',
]);
api_fixtures()->createPasskey([
'user_id' => (int)$subuser['id'],
'is_subuser' => true,
'credential_id' => 'subuser-passkey-credential',
]);
$subuserChallenge = api_client()->post('/auth/passkey/challenge', [
'principal_type' => 'subuser',
'username' => 'passkey-driver',
]);
$userChallenge = api_client()->post('/auth/passkey/challenge', [
'principal_type' => 'user',
'customer_number' => (int)$user['customer_number'],
]);
$subuserChallenge
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$userChallenge
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$subuserCredentials = $subuserChallenge->data()['publicKey']['allowCredentials'] ?? [];
$userCredentials = $userChallenge->data()['publicKey']['allowCredentials'] ?? [];
$subuserCredentialIds = array_column($subuserCredentials, 'id');
$userCredentialIds = array_column($userCredentials, 'id');
expect($subuserCredentialIds)->toContain('subuser-passkey-credential');
expect($subuserCredentialIds)->not->toContain('customer-passkey-credential');
expect($userCredentialIds)->toContain('customer-passkey-credential');
expect($userCredentialIds)->not->toContain('subuser-passkey-credential');
$tokenRepo = new \objects\tokens_o();
$subuserChallengeToken = (string)($subuserChallenge->data()['challenge_token'] ?? '');
$userChallengeToken = (string)($userChallenge->data()['challenge_token'] ?? '');
if ($subuserChallengeToken !== '') {
$tokenRepo->delete($subuserChallengeToken);
}
if ($userChallengeToken !== '') {
$tokenRepo->delete($userChallengeToken);
}
});
it('rejects invalid logout tokens', function (): void {
api_test_covers('GET /auth/logout', 'auth');
@@ -36,6 +36,15 @@ it('serves installer artifacts and recovers gateway runtime status after fresh h
->and($artifact->body)
->toContain('<?php');
$manifest = api_client()->get('/edge-agent/artifacts/manifest.json');
expect($manifest->status)->toBe(200);
$manifestPayload = json_decode($manifest->body, true);
expect($manifestPayload)->toBeArray()
->and($manifestPayload['version'] ?? null)->toBe(edge_gateway_manager::DEFAULT_INSTALL_VERSION);
$manifestArtifacts = array_column((array)($manifestPayload['artifacts'] ?? []), null, 'name');
expect($manifestArtifacts)->toHaveKey('agent.php')
->and($manifestArtifacts['agent.php']['sha256'] ?? null)->toBe(hash('sha256', $artifact->body));
$claimResponse = api_client()->post('/edge-agent/claim', [
'token' => (string)$installToken['token'],
'hostname' => 'edge-agent-api',
@@ -44,11 +44,13 @@ it('lists subusers when an existing grant has legacy zero permissions', function
expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]);
});
it('uses the same password policy for subuser setup and password auth', function (): void {
it('enforces the password policy for setup without rejecting legacy valid passwords at login', function (): void {
api_test_covers('POST /subusers/setup', 'failure');
api_test_covers('POST /subusers/auth/password', 'failure');
api_test_covers('POST /subusers/auth/password', 'happy');
$subuser = api_fixtures()->createSubuser();
$subuser = api_fixtures()->createSubuser([
'password_plaintext' => 'invalidpassword',
]);
$setupResponse = api_client()->post('/subusers/setup', [
'token' => 'policy-test-token',
@@ -68,10 +70,114 @@ it('uses the same password policy for subuser setup and password auth', function
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
$authResponse
->assertStatus(400)
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($authResponse->data()['session'] ?? null)->toBeString();
(new \objects\subusers_o())->invalidateSessionToken((string)$authResponse->data()['session']);
});
it('returns a generic error for invalid subuser credentials', function (): void {
api_test_covers('POST /subusers/auth/password', 'failure');
$response = api_client()->post('/subusers/auth/password', [
'subuser_id' => 999999999,
'password' => 'whatever',
]);
$response
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
->assertMessage('Invalid credentials');
});
it('requires subuser management access before exposing permission nodes', function (): void {
api_test_covers('GET /subusers/permission-nodes', 'auth');
$unauthenticated = api_client()->get('/subusers/permission-nodes');
$unauthenticated
->assertStatus(401)
->assertEnvelope()
->assertSuccess(false);
$session = api_fixtures()->createUserSession(['list_own_subusers']);
$authorized = api_client()->get('/subusers/permission-nodes', $session['headers']);
$authorized
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($authorized->data())->toBeArray()->not->toBeEmpty();
});
it('rejects customer subuser listing without own-scope permission', function (): void {
api_test_covers('GET /subusers', 'auth');
$session = api_fixtures()->createUserSession(['user']);
$response = api_client()->get('/subusers?page=1&limit=5', $session['headers']);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false);
});
it('replaces older setup tokens when a new setup token is generated', function (): void {
$subuser = api_fixtures()->createSubuser([
'password_plaintext' => null,
'name' => 'Pending Setup Driver',
]);
$subuserObject = (new \objects\subusers_o())->select((int)$subuser['id']);
$subuserObject->getObjectProperties();
$firstToken = $subuserObject->generateSetupToken();
$secondToken = $subuserObject->generateSetupToken();
try {
expect((new \objects\subusers_o())->getSubuserBySetupToken($firstToken))->toBeNull();
expect((new \objects\subusers_o())->getSubuserBySetupToken($secondToken))->not->toBeNull();
} finally {
(new \objects\subusers_o())->invalidateSetupToken($secondToken);
}
});
it('validates setup identifiers before setting the driver password', function (): void {
api_test_covers('POST /subusers/setup', 'failure');
$existing = api_fixtures()->createSubuser([
'username' => 'existing-driver-setup',
]);
$pending = api_fixtures()->createSubuser([
'password_plaintext' => null,
'username' => 'pending-driver-setup',
'name' => 'Pending Driver',
]);
$pendingObject = (new \objects\subusers_o())->select((int)$pending['id']);
$pendingObject->getObjectProperties();
$token = $pendingObject->generateSetupToken();
try {
$response = api_client()->post('/subusers/setup', [
'token' => $token,
'name' => 'Pending Driver',
'username' => $existing['username'],
'password' => 'ValidPass123',
]);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Account already exists with this username');
$row = api_fixtures()->fetchRowById('subusers', (int)$pending['id']);
expect($row['password'] ?? null)->toBeNull();
expect((new \objects\subusers_o())->getSubuserBySetupToken($token))->not->toBeNull();
} finally {
(new \objects\subusers_o())->invalidateSetupToken($token);
}
});
it('lists chauffeur grants across customers for superusers', function (): void {
@@ -916,14 +916,16 @@ final class ApiFixtures
public function createSubuser(array $attributes = []): array
{
$username = (string)($attributes['username'] ?? ('api-subuser-' . strtolower($this->uniqueSuffix())));
$passwordPlaintext = (string)($attributes['password_plaintext'] ?? 'Secret123!');
$passwordPlaintext = array_key_exists('password_plaintext', $attributes)
? $attributes['password_plaintext']
: 'Secret123!';
$name = (string)($attributes['name'] ?? 'API Subuser');
$email = (string)($attributes['email'] ?? ($username . '@example.test'));
$now = $this->now();
$subuserId = $this->insertRow('subusers', [
'username' => $username,
'password' => password_hash($passwordPlaintext, PASSWORD_DEFAULT),
'password' => $passwordPlaintext === null ? null : password_hash((string)$passwordPlaintext, PASSWORD_DEFAULT),
'name' => $name,
'email' => $email,
'phone_country_code' => 45,
@@ -1064,6 +1066,42 @@ final class ApiFixtures
return $token;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createPasskey(array $attributes): array
{
$userId = (int)($attributes['user_id'] ?? 0);
if ($userId <= 0) {
throw new RuntimeException('Passkey fixtures require user_id.');
}
$credentialId = (string)($attributes['credential_id'] ?? ('credential-' . strtolower($this->uniqueSuffix())));
$passkeyId = $this->insertRow('passkeys', [
'user_id' => $userId,
'is_subuser' => !empty($attributes['is_subuser']) ? 1 : 0,
'credential_id' => $credentialId,
'public_key' => $attributes['public_key'] ?? str_repeat('A', 64),
'algorithm' => $attributes['algorithm'] ?? 'ES256',
'transports' => json_encode($attributes['transports'] ?? ['internal'], JSON_UNESCAPED_SLASHES),
'sign_count' => (int)($attributes['sign_count'] ?? 0),
'backup_state' => json_encode($attributes['backup_state'] ?? new \stdClass(), JSON_UNESCAPED_SLASHES),
'name' => $attributes['name'] ?? 'API test passkey',
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('passkeys', $passkeyId));
return [
'id' => $passkeyId,
'credential_id' => $credentialId,
'user_id' => $userId,
];
}
public function addCustomerAttribute(int $userId, string $attribute): int
{
$attributeId = $this->insertRow('customer_attributes', [
@@ -65,12 +65,16 @@ function remove_edge_gateway_temp_path(string $path): void
it('resolves edge-agent artifacts from a supported runtime layout', function (): void {
$path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path());
$normalizedPath = str_replace('\\', '/', $path);
expect(str_replace('\\', '/', $path))->toEndWith('/resources/edge-gateway-agent/agent.php');
expect(
str_ends_with($normalizedPath, '/edge-agent/build/install/agent.php')
|| str_ends_with($normalizedPath, '/resources/edge-gateway-agent/agent.php')
)->toBeTrue();
expect(is_file($path))->toBeTrue();
});
it('prioritizes router resources before mounted and baked-in artifact directories', function (): void {
it('prioritizes generated edge-agent build output before router, mounted, and baked-in artifact directories', function (): void {
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void {
$candidatePaths = array_map(
static fn(string $path): string => str_replace('\\', '/', $path),
@@ -82,16 +86,75 @@ it('prioritizes router resources before mounted and baked-in artifact directorie
)
);
expect(array_slice($candidatePaths, 0, 3))->toBe([
expect(array_slice($candidatePaths, 0, 5))->toBe([
'/edge-agent/build/install/agent.php',
'/services/edge-agent/build/install/agent.php',
'/var/edge-agent/build/install/agent.php',
'/var/www/edge-agent/build/install/agent.php',
'/var/www/html/resources/edge-gateway-agent/agent.php',
'/services/edge-agent/php-agent/agent.php',
'/opt/truckwash-edge-agent-artifacts/agent.php',
]);
expect($candidatePaths)->toContain('/services/edge-agent/php-agent/agent.php');
expect($candidatePaths)->toContain('/opt/truckwash-edge-agent-artifacts/agent.php');
expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php');
expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php');
});
});
it('prioritizes EDGE_AGENT_ARTIFACT_DIR before generated edge-agent build output', function (): void {
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => '/tmp/custom-edge-artifacts'], function (): void {
$candidatePaths = array_map(
static fn(string $path): string => str_replace('\\', '/', $path),
edge_gateway_agent_artifact_locator::candidatePaths(
'agent.php',
'/var/www/html',
'/services/edge-agent/php-agent',
'/opt/truckwash-edge-agent-artifacts'
)
);
expect(array_slice($candidatePaths, 0, 4))->toBe([
'/tmp/custom-edge-artifacts/agent.php',
'/edge-agent/build/install/agent.php',
'/services/edge-agent/build/install/agent.php',
'/var/edge-agent/build/install/agent.php',
]);
expect($candidatePaths)->toContain('/var/www/html/resources/edge-gateway-agent/agent.php');
});
});
it('serves a manifest with hashes for the resolved artifacts', function (): void {
$service = new EdgeGatewayInstallServiceHarness();
$manifest = json_decode($service->readArtifact('manifest.json'), true);
expect($manifest)->toBeArray()
->and($manifest['version'] ?? null)->toBe(edge_gateway_manager::DEFAULT_INSTALL_VERSION);
$artifacts = [];
foreach ((array)($manifest['artifacts'] ?? []) as $artifact) {
if (is_array($artifact)) {
$artifacts[(string)($artifact['name'] ?? '')] = $artifact;
}
}
foreach ([
'agent.php',
'lan-worker.php',
'auto-updater.php',
'docker-compose.gateway.yml',
'Dockerfile.edge-agent',
'Dockerfile.lan-worker',
'Dockerfile.auto-updater',
'gateway-launcher.sh',
'truckwash-edge-gateway-stack.service',
'truckwash-edge-agent.service',
] as $fileName) {
$path = edge_gateway_agent_artifact_locator::resolve($fileName, app_path());
expect($artifacts)->toHaveKey($fileName)
->and($artifacts[$fileName]['sha256'] ?? null)->toBe(hash_file('sha256', $path))
->and($artifacts[$fileName]['bytes'] ?? null)->toBe(filesize($path));
}
});
it('reads install artifacts through the shared locator', function (): void {
$service = new EdgeGatewayInstallServiceHarness();
$contents = $service->readArtifact('truckwash-edge-gateway-stack.service');
@@ -75,10 +75,13 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"');
expect($script)->toContain('fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json" "$INSTALL_DIR/manifest.json"');
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
expect($script)->toContain('begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"');
expect($script)->toContain('verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"');
expect($script)->toContain('report_install_status() {');
expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"');
expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"');
@@ -98,6 +101,7 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
expect($script)->toContain('"operationPollTimeoutSeconds":20');
expect($script)->toContain('"installedVersion":"compose-php-agent-v3"');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"');
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
});
@@ -34,6 +34,7 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
expect($route)->toContain("'/edge-agent/install-token/verify'");
expect($route)->toContain("'/edge-agent/install-token/status'");
expect($route)->toContain("'/edge-agent/install.sh'");
expect($route)->toContain("'/edge-agent/artifacts/manifest.json'");
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'");
@@ -24,13 +24,19 @@ it('builds the installer around the compose stack artifacts and management polli
expect($autoUpdaterSource)->not->toBeFalse();
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
expect($managerSource)->toContain("public const DEFAULT_INSTALL_VERSION = 'compose-php-agent-v3'");
expect($managerSource)->toContain("'installedVersion' => self::DEFAULT_INSTALL_VERSION");
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
expect($managerSource)->toContain('fetch_http "Download artifact manifest" "__MANIFEST_URL__" "$INSTALL_DIR/manifest.json"');
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"');
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"');
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
expect($managerSource)->toContain('verify_manifest_artifact()');
expect($managerSource)->toContain('begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"');
expect($managerSource)->toContain('run_step "Verifying PHP edge agent" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"');
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3');
@@ -72,6 +78,8 @@ it('builds the installer around the compose stack artifacts and management polli
expect($managerSource)->not->toContain('agent.mjs');
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
expect($installServiceSource)->toContain('normalizeLineEndings($this->manager()->buildInstallScript($plainToken))');
expect($installServiceSource)->toContain("'manifest.json' => 'application/json; charset=utf-8'");
expect($installServiceSource)->toContain("'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION");
expect($installServiceSource)->toContain('str_replace(["\r\n", "\r"], "\n", $contents)');
expect($serviceSource)->toContain('Description=TruckWash Edge Agent Compatibility Unit');
expect($serviceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
@@ -129,6 +137,7 @@ it('builds the installer around the compose stack artifacts and management polli
expect($composeSource)->toContain('container_name: truckwash-minio');
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
expect($agentSource)->toContain('private string $controlPlaneStatusPath;');
expect($agentSource)->toContain('compose-php-agent-v3');
expect($agentSource)->toContain('control-plane-status.json');
expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()");
expect($agentSource)->toContain("'last_heartbeat_attempt_at'");