From 4b3817146aa79e7596aeddbb22846d18eff1d311 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 12 Sep 2025 15:44:57 -0700 Subject: [PATCH 01/17] Initial attempt at TenantBootstrapper --- ProcessMaker/Multitenancy/Tenant.php | 7 ++ .../Multitenancy/TenantBootstrapper.php | 68 +++++++++++++++++++ ProcessMaker/Multitenancy/TenantFinder.php | 5 ++ .../Providers/ProcessMakerServiceProvider.php | 7 ++ 4 files changed, 87 insertions(+) create mode 100644 ProcessMaker/Multitenancy/TenantBootstrapper.php diff --git a/ProcessMaker/Multitenancy/Tenant.php b/ProcessMaker/Multitenancy/Tenant.php index 1b87dd5d5e..c680124f9a 100644 --- a/ProcessMaker/Multitenancy/Tenant.php +++ b/ProcessMaker/Multitenancy/Tenant.php @@ -6,10 +6,17 @@ class Tenant extends SpatieTenant { + const BOOTSTRAPPED_TENANT = 'bootstrappedTenant'; + protected $guarded = []; protected $casts = [ 'config' => 'array', 'password' => 'encrypted', ]; + + public static function fromBootstrapper() + { + return (new static())->newFromBuilder(app(self::BOOTSTRAPPED_TENANT)); + } } diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php new file mode 100644 index 0000000000..9ae21084f3 --- /dev/null +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -0,0 +1,68 @@ + Env::get('DB_HOSTNAME', 'localhost'), + 'port' => Env::get('DB_PORT', '3306'), + 'database' => Env::get('LANDLORD_DB_DATABASE', 'landlord'), + 'username' => Env::get('DB_USERNAME'), + 'password' => Env::get('DB_PASSWORD'), + 'charset' => 'utf8mb4', + ]; + + try { + // Create PDO connection to landlord database + $dsn = "mysql:host={$landlordConfig['host']};port={$landlordConfig['port']};dbname={$landlordConfig['database']};charset={$landlordConfig['charset']}"; + $pdo = new PDO($dsn, $landlordConfig['username'], $landlordConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + $tenantData = null; + + // Try to find tenant by ID first if TENANT env var is set + $envTenant = Env::get('TENANT'); + if ($envTenant) { + $stmt = $pdo->prepare('SELECT * FROM tenants WHERE id = ?'); + $stmt->execute([$envTenant]); + $tenantData = $stmt->fetch(); + } else { + $request = Request::capture(); + $host = $request->getHost(); + $stmt = $pdo->prepare('SELECT * FROM tenants WHERE domain = ? LIMIT 1'); + $stmt->execute([$host]); + $tenantData = $stmt->fetch(); + } + + $app->instance(Tenant::BOOTSTRAPPED_TENANT, $tenantData); + } catch (PDOException $e) { + // Log the error but don't throw to avoid breaking the bootstrap process + error_log('TenantBootstrapper Failed: ' . $e->getMessage()); + + return null; + } + } +} diff --git a/ProcessMaker/Multitenancy/TenantFinder.php b/ProcessMaker/Multitenancy/TenantFinder.php index bfc986f17c..c85f068728 100644 --- a/ProcessMaker/Multitenancy/TenantFinder.php +++ b/ProcessMaker/Multitenancy/TenantFinder.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Env; +use ProcessMaker\Multitenancy\Tenant; use Spatie\Multitenancy\Contracts\IsTenant; use Spatie\Multitenancy\TenantFinder\DomainTenantFinder; @@ -11,6 +12,10 @@ class TenantFinder extends DomainTenantFinder { public function findForRequest(Request $request): ?IsTenant { + if (app()->has(Tenant::BOOTSTRAPPED_TENANT)) { + return Tenant::fromBootstrapper(); + } + $tenant = null; $message = null; diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 0e2710709e..d86fdbe106 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -587,6 +587,13 @@ private function setCurrentTenantForConsoleCommands(): void return; } + if (app()->has(Tenant::BOOTSTRAPPED_TENANT)) { + $tenant = Tenant::fromBootstrapper(); + $tenant->makeCurrent(); + + return; + } + $tenantId = Env::get('TENANT'); if ($tenantId) { $tenant = Tenant::findOrFail($tenantId); From a5f6899c992330237259dbd3639018674ff58fa0 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Mon, 15 Sep 2025 11:59:00 -0700 Subject: [PATCH 02/17] Insert TenantBootstrapper after LoadEnvironmentVariables --- ProcessMaker/Application.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ProcessMaker/Application.php b/ProcessMaker/Application.php index 023fc26c4f..38e37d4aff 100644 --- a/ProcessMaker/Application.php +++ b/ProcessMaker/Application.php @@ -5,8 +5,10 @@ use Igaster\LaravelTheme\Facades\Theme; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application as IlluminateApplication; +use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; use Illuminate\Foundation\PackageManifest; use Illuminate\Support\Facades\Auth; +use ProcessMaker\Multitenancy\TenantBootstrapper; /** * Class Application. @@ -90,4 +92,15 @@ public function registerConfiguredProviders() parent::registerConfiguredProviders(); } + + public function bootstrapWith(array $bootstrappers) + { + // Insert TenantBootstrapper after LoadEnvironmentVariables + $index = array_search(LoadEnvironmentVariables::class, $bootstrappers); + if ($index !== false) { + array_splice($bootstrappers, $index + 1, 0, [TenantBootstrapper::class]); + } + + return parent::bootstrapWith($bootstrappers); + } } From c65746d5cc5894987106dd5e3729f606f97833e9 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Tue, 23 Sep 2025 07:16:33 -0700 Subject: [PATCH 03/17] Remove hack since this was fixed in symfony --- ProcessMaker/Exception/Handler.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/ProcessMaker/Exception/Handler.php b/ProcessMaker/Exception/Handler.php index 86dd173454..f1bac12c77 100644 --- a/ProcessMaker/Exception/Handler.php +++ b/ProcessMaker/Exception/Handler.php @@ -146,18 +146,4 @@ protected function convertExceptionToArray(Throwable $e) 'message' => $this->isHttpException($e) ? $e->getMessage() : 'Server Error', ]; } - - /** - * Errors in the console must have an exit status > 0 for CI to see it as an error. - * This prevents the symfony console from handling the error and returning an - * exit status of 0, which it does by default surprisingly. - * - * @param \Symfony\Component\Console\Output\OutputInterface $output - * @param Throwable $e - * @return void - */ - public function renderForConsole($output, Throwable $e) - { - throw $e; - } } From 2edc7c160e43d14b8ed4627bbce989fe55720b75 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Thu, 25 Sep 2025 17:35:31 -0700 Subject: [PATCH 04/17] Refactor multitenant configuration --- .../Console/Commands/TenantsVerify.php | 112 +++++------ ProcessMaker/Exception/Handler.php | 5 + ProcessMaker/LicensedPackageManifest.php | 17 +- ProcessMaker/Multitenancy/SwitchTenant.php | 162 ++++------------ ProcessMaker/Multitenancy/Tenant.php | 24 ++- .../Multitenancy/TenantBootstrapper.php | 175 ++++++++++++++---- ProcessMaker/Multitenancy/TenantFinder.php | 4 +- .../Providers/ProcessMakerServiceProvider.php | 73 +------- config/logging.php | 4 +- 9 files changed, 260 insertions(+), 316 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsVerify.php b/ProcessMaker/Console/Commands/TenantsVerify.php index c5294012fc..07740f6bb9 100644 --- a/ProcessMaker/Console/Commands/TenantsVerify.php +++ b/ProcessMaker/Console/Commands/TenantsVerify.php @@ -4,7 +4,10 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; +use ProcessMaker\Models\EnvironmentVariable; +use ProcessMaker\Models\User; use Spatie\Multitenancy\Models\Tenant; class TenantsVerify extends Command @@ -14,7 +17,7 @@ class TenantsVerify extends Command * * @var string */ - protected $signature = 'tenants:verify {--verify-against= : The tenant ID to verify against}'; + protected $signature = 'tenants:verify'; /** * The console command description. @@ -46,8 +49,6 @@ public function handle() $currentTenant = app('currentTenant'); } - $verifyAgainstId = $this->option('verify-against'); - if (!$currentTenant) { $this->error('No current tenant found'); @@ -55,76 +56,49 @@ public function handle() } $this->info('Current Tenant ID: ' . $currentTenant->id); - $this->line('----------------------------------------'); - - // Expected paths and configurations - $expectedStoragePath = base_path('storage/tenant_' . $currentTenant->id); - $actualConfigs = [ - 'filesystems.disks.local.root' => storage_path('app'), - 'cache.prefix' => config('cache.prefix'), - 'app.url' => config('app.url'), - 'script-runner-microservice.callback' => config('script-runner-microservice.callback'), - ]; - - // Display current values - $this->info('Current Storage Path: ' . storage_path()); - $this->line('----------------------------------------'); - $this->info('Current Configuration Values:'); - foreach ($actualConfigs as $key => $expectedValue) { - $currentValue = config($key); - $this->line("{$key}: {$currentValue}"); - } + $paths = [ + ['Storage Path', storage_path()], + ['Config Cache Path', app()->getCachedConfigPath()], + ['Lang Path', lang_path()], + ]; - // If verify-against is specified, perform verification - if ($verifyAgainstId) { - $this->line('----------------------------------------'); - $this->info("Verifying against tenant ID: {$verifyAgainstId}"); + // Display paths in a nice table + $this->table(['Path', 'Value'], $paths); + + $configs = [ + 'app.key', + 'app.url', + 'app.instance', + 'cache.prefix', + 'database.redis.options.prefix', + 'cache.stores.cache_settings.prefix', + 'script-runner-microservice.callback', + 'database.connections.processmaker.database', + 'logging.channels.daily.path', + ]; - $expectedStoragePath = base_path('storage/tenant_' . $verifyAgainstId); - $expectedConfigs = [ - 'filesystems.disks.local.root' => $expectedStoragePath . '/app', - 'cache.prefix' => 'tenant_id_' . $verifyAgainstId, - 'app.url' => config('app.url'), + $configs = array_map(function ($config) { + return [ + $config, + config($config), ]; + }, $configs); + + // Display configs in a nice table + $this->table(['Config', 'Value'], $configs); + + $other = [ + ['Landlord Config Cache Path', base_path('bootstrap/cache/config.php')], + ['Landlord Config Is Cached', File::exists(base_path('bootstrap/cache/config.php')) ? 'Yes' : 'No'], + ['Tenant Config Cache Path', app()->getCachedConfigPath()], + ['Tenant Config Is Cached', File::exists(app()->getCachedConfigPath()) ? 'Yes' : 'No'], + ['First username (database check)', User::first()->username], + ['First environment variable (decrypted check)', substr(EnvironmentVariable::first()->value, 0, 15)], + ['Original App URL (landlord)', $currentTenant->getOriginalValue('APP_URL')], + ]; - $hasMismatch = false; - - // Verify storage path - if (storage_path() !== $expectedStoragePath) { - $this->error('Storage path mismatch!'); - $this->line("Expected: {$expectedStoragePath}"); - $this->line('Current: ' . storage_path()); - $hasMismatch = true; - } - - // Verify tenant URL if tenant exists - $verifyTenant = Tenant::find($verifyAgainstId); - if ($verifyTenant && $verifyTenant->domain !== $this->stripProtocol(config('app.url'))) { - $this->error('Tenant URL mismatch!'); - $this->line("Expected: {$verifyTenant->domain}"); - $this->line('Current: ' . config('app.url')); - $hasMismatch = true; - } - - // Verify config values - foreach ($expectedConfigs as $key => $expectedValue) { - $currentValue = config($key); - if ($currentValue !== $expectedValue) { - $this->error("Config mismatch for {$key}!"); - $this->line("Expected: {$expectedValue}"); - $this->line("Current: {$currentValue}"); - $hasMismatch = true; - } - } - - if (!$hasMismatch) { - $this->info('All configurations match as expected!'); - } - - return $hasMismatch ? Command::FAILURE : Command::SUCCESS; - } - - return Command::SUCCESS; + // Display other in a nice table + $this->table(['Other', 'Value'], $other); } } diff --git a/ProcessMaker/Exception/Handler.php b/ProcessMaker/Exception/Handler.php index f1bac12c77..3566ca8664 100644 --- a/ProcessMaker/Exception/Handler.php +++ b/ProcessMaker/Exception/Handler.php @@ -43,6 +43,11 @@ class Handler extends ExceptionHandler */ public function report(Throwable $exception) { + if (!App::getFacadeRoot()) { + error_log(get_class($exception) . ': ' . $exception->getMessage()); + + return; + } if (App::environment() == 'testing' && env('TESTING_VERBOSE')) { // If we're verbose, we should print ALL Exceptions to the screen echo $exception->getMessage() . "\n"; diff --git a/ProcessMaker/LicensedPackageManifest.php b/ProcessMaker/LicensedPackageManifest.php index abed47193e..94048a176e 100644 --- a/ProcessMaker/LicensedPackageManifest.php +++ b/ProcessMaker/LicensedPackageManifest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use ProcessMaker\Providers\ProcessMakerServiceProvider; +use Spatie\Multitenancy\MultitenancyServiceProvider; use Throwable; class LicensedPackageManifest extends PackageManifest @@ -20,18 +22,13 @@ class LicensedPackageManifest extends PackageManifest const LAST_PACKAGE_DISCOVERY = 0; - /** - * Consider this the beginning of licenesing refactor for multitenancy. - * - * For now, this will just move the Spatie MultitenancyServiceProvider to the beginning of the service providers. - */ - protected function getManifest() + public function providers() { - $manifest = parent::getManifest(); - $multitenancyKey = 'spatie/laravel-multitenancy'; + $providers = parent::providers(); + array_unshift($providers, ProcessMakerServiceProvider::class); + array_unshift($providers, MultitenancyServiceProvider::class); - // Make sure the MultitenancyServiceProvider is at the beginning of the manifest - return [$multitenancyKey => $manifest[$multitenancyKey]] + $manifest; + return $providers; } protected function packagesToIgnore() diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index e522798a8c..06ba0e0ab9 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -3,13 +3,8 @@ namespace ProcessMaker\Multitenancy; use Illuminate\Broadcasting\BroadcastManager; -use Illuminate\Bus\Dispatcher; -use Illuminate\Contracts\Queue\Factory as QueueFactoryContract; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Crypt; -use Illuminate\Support\Facades\DB; +use ProcessMaker\Application; use ProcessMaker\Multitenancy\Broadcasting\TenantAwareBroadcastManager; -use ProcessMaker\Multitenancy\TenantAwareDispatcher; use Spatie\Multitenancy\Concerns\UsesMultitenancyConfig; use Spatie\Multitenancy\Contracts\IsTenant; use Spatie\Multitenancy\Tasks\SwitchTenantTask; @@ -18,8 +13,6 @@ class SwitchTenant implements SwitchTenantTask { use UsesMultitenancyConfig; - public static $originalConfig = null; - /** * Make the given tenant current. * @@ -28,103 +21,22 @@ class SwitchTenant implements SwitchTenantTask */ public function makeCurrent(IsTenant $tenant): void { - \Log::debug('SwitchTenant: ' . $tenant->id, ['domain' => request()->getHost()]); + $app = app(); - $this->setTenantDatabaseConnection($tenant); + \Log::debug('SwitchTenant: ' . $tenant->id, ['domain' => request()->getHost()]); // Set the tenant's domain in the request headers. Used for things like the global url() helper. request()->headers->set('host', $tenant->domain); - // Set the tenant-specific storage path - $tenantStoragePath = base_path('storage/tenant_' . $tenant->id); - - $app = app(); - $app->useStoragePath($tenantStoragePath); - // Use tenant's translation files $app->useLangPath(resource_path('lang/tenant_' . $tenant->id)); - // Create the tenant storage directory if it doesn't exist - // TODO: Move these to somewhere else - should not be run on every request - if (!file_exists($tenantStoragePath)) { - mkdir($tenantStoragePath, 0755, true); - } - - // Any config that relies on the original value needs to be saved in a static variable. - // Otherwise, it will use the last tenant's modified value. This mostly only affects - // the worker queue jobs since it reuses the same process. - self::$originalConfig = self::$originalConfig ?? []; - self::$originalConfig[$tenant->id] = self::$originalConfig[$tenant->id] ?? [ - 'app.url' => config('app.url'), - 'cache.stores.cache_settings.prefix' => config('cache.stores.cache_settings.prefix'), - 'app.instance' => config('app.instance') ?? config('database.connections.landlord.database'), - 'script-runner-microservice.callback' => config('script-runner-microservice.callback'), - ]; - - // We cant reload config here with (new LoadConfiguration())->bootstrap($app); - // because it overrides dynamic configs set in packages (like docker-executor-php) - // Instead, override each necessary config value on the fly. - $newConfig = [ - 'filesystems.disks.local.root' => storage_path('app'), - 'filesystems.disks.public.root' => storage_path('app/public'), - 'filesystems.disks.public.url' => $tenant->config['app.url'] . '/storage', - 'filesystems.disks.profile.root' => storage_path('app/public/profile'), - 'filesystems.disks.profile.url' => $tenant->config['app.url'] . '/storage/profile', - 'filesystems.disks.settings.root' => storage_path('app/public/setting'), - 'filesystems.disks.settings.url' => $tenant->config['app.url'] . '/storage/setting', - 'filesystems.disks.private_settings.root' => storage_path('app/private/settings'), - 'filesystems.disks.web_services.root' => storage_path('app/private/web_services'), - 'filesystems.disks.tmp.root' => storage_path('app/public/tmp'), - 'filesystems.disks.tmp.url' => $tenant->config['app.url'] . '/storage/tmp', - 'filesystems.disks.samlidp.root' => storage_path('samlidp'), - 'filesystems.disks.decision_tables.root' => storage_path('decision-tables'), - 'filesystems.disks.decision_tables.url' => $tenant->config['app.url'] . '/storage/decision-tables', - 'filesystems.disks.lang.root' => lang_path(), - 'l5-swagger.defaults.paths.docs' => storage_path('api-docs'), - 'app.instance' => self::$originalConfig[$tenant->id]['app.instance'] . '_' . $tenant->id, - ]; - - if (!isset($tenant->config['cache.stores.cache_settings.prefix'])) { - $newConfig['cache.stores.cache_settings.prefix'] = - 'tenant_id_' . $tenant->id . ':' . self::$originalConfig[$tenant->id]['cache.stores.cache_settings.prefix']; - } - - if (!isset($tenant->config['script-runner-microservice.callback'])) { - $newConfig['script-runner-microservice.callback'] = str_replace( - self::$originalConfig[$tenant->id]['app.url'], - $tenant->config['app.url'], - self::$originalConfig[$tenant->id]['script-runner-microservice.callback'] - ); - } - - if (!isset($tenant->config['app.docker_host_url'])) { - // There is no specific override in the tenant's config so set it to the app url - $newConfig['app.docker_host_url'] = $tenant->config['app.url']; - } - - config($newConfig); - - // Set config from the entry in the tenants table - $config = $tenant->config; - if (isset($config['app.key'])) { - // Decrypt using the landlord APP_KEY in the .env file. - // All encryption after this will use the tenant's key. - $config['app.key'] = Crypt::decryptString($config['app.key']); - } - config($config); - - // The previous app key was saved in the singleton, so we need to forget it. - $app->forgetInstance('encrypter'); + $this->overrideConfigs($app, $tenant); // Extend BroadcastManager to our custom implementation that prefixes the channel names with the tenant id. $app->extend(BroadcastManager::class, function ($manager, $app) use ($tenant) { return new TenantAwareBroadcastManager($app, $tenant->id); }); - - // Extend Dispatcher to our custom implementation that prefixes the queue names with the tenant id. - $app->extend(Dispatcher::class, function ($dispatcher, $app) use ($tenant) { - return new TenantAwareDispatcher($app, $dispatcher, $tenant->id); - }); } /** @@ -136,51 +48,43 @@ public function forgetCurrent(): void { } - /** - * Set the tenant database connection. - * - * Copied from laravel-multitenancy's src/Tasks/SwitchTenantDatabaseTask.php - * - * @param IsTenant $tenant - * @return void - */ - private function setTenantDatabaseConnection(IsTenant $tenant): void + private function overrideConfigs(Application $app, IsTenant $tenant) { - $tenantConnectionName = $this->tenantDatabaseConnectionName(); - - $tenantDBKey = "database.connections.{$tenantConnectionName}"; - - $databaseName = $tenant->getDatabaseName(); - $username = $tenant->username; - $password = $tenant->password; + if ($app->configurationIsCached()) { + return; + } - $setConfig = [ - "{$tenantDBKey}.database" => $databaseName, + $newConfig = [ + 'app.instance' => config('app.instance') . '_' . $tenant->id, ]; - if ($username) { - $setConfig["{$tenantDBKey}.username"] = $username; + + if (!isset($tenant->config['cache.stores.cache_settings.prefix'])) { + $newConfig['cache.stores.cache_settings.prefix'] = + 'tenant_id_' . $tenant->id . ':' . $tenant->getOriginalValue('CACHE_SETTING_PREFIX'); } - if ($password) { - $setConfig["{$tenantDBKey}.password"] = $password; + + if (!isset($tenant->config['script-runner-microservice.callback'])) { + $newConfig['script-runner-microservice.callback'] = str_replace( + $tenant->getOriginalValue('APP_URL'), + config('app.url'), + $tenant->getOriginalValue('SCRIPT_MICROSERVICE_CALLBACK') + ); } - config($setConfig); + if (!isset($tenant->config['app.docker_host_url'])) { + // There is no specific override in the tenant's config so set it to the app url + $newConfig['app.docker_host_url'] = config('app.url'); + } - app('db')->extend($tenantConnectionName, function ($config, $name) use ($databaseName, $username, $password) { - $config['database'] = $databaseName; - if ($username) { - $config['username'] = $username; - } - if ($password) { - $config['password'] = $password; + // Set config from the entry in the tenants table + $config = $tenant->config; + foreach ($config as $key => $value) { + if ($key === 'app.key' || $key === 'app.url') { + continue; } + $newConfig[$key] = $value; + } - return app('db.factory')->make($config, $name); - }); - - DB::purge($tenantConnectionName); - - // Octane will have an old `db` instance in the Model::$resolver. - Model::setConnectionResolver(app('db')); + config($newConfig); } } diff --git a/ProcessMaker/Multitenancy/Tenant.php b/ProcessMaker/Multitenancy/Tenant.php index c680124f9a..5eca3b79d7 100644 --- a/ProcessMaker/Multitenancy/Tenant.php +++ b/ProcessMaker/Multitenancy/Tenant.php @@ -2,6 +2,8 @@ namespace ProcessMaker\Multitenancy; +use Illuminate\Support\Facades\Crypt; +use ProcessMaker\Application; use Spatie\Multitenancy\Models\Tenant as SpatieTenant; class Tenant extends SpatieTenant @@ -10,13 +12,33 @@ class Tenant extends SpatieTenant protected $guarded = []; + // Non-persistent + public $originalValues = null; + protected $casts = [ 'config' => 'array', 'password' => 'encrypted', ]; + public static function setBootstrappedTenant(Application $app, ?array $tenantData) + { + $app->instance(self::BOOTSTRAPPED_TENANT, $tenantData); + } + public static function fromBootstrapper() { - return (new static())->newFromBuilder(app(self::BOOTSTRAPPED_TENANT)); + if (app()->has(self::BOOTSTRAPPED_TENANT)) { + $tenant = (new static())->newFromBuilder(app(self::BOOTSTRAPPED_TENANT)); + $tenant->originalValues = app(self::BOOTSTRAPPED_TENANT)['original_values']; + + return $tenant; + } + + return null; + } + + public function getOriginalValue($key = null) + { + return $this->originalValues[$key]; } } diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php index 9ae21084f3..25873620bf 100644 --- a/ProcessMaker/Multitenancy/TenantBootstrapper.php +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -2,10 +2,10 @@ namespace ProcessMaker\Multitenancy; +use Illuminate\Encryption\Encrypter; use Illuminate\Http\Request; use Illuminate\Support\Env; use PDO; -use PDOException; use ProcessMaker\Application; use ProcessMaker\Multitenancy\Tenant; @@ -17,52 +17,157 @@ */ class TenantBootstrapper { + private $encrypter = null; + + private $pdo = null; + + private $originalValues = null; + public function bootstrap(Application $app) { - if (!Env::get('MULTITENANCY')) { + if (!$this->env('MULTITENANCY')) { + return; + } + + // We need to save the original values for running horizon + $this->saveOriginalValues(); + + $tenantData = null; + + // Try to find tenant by ID first if TENANT env var is set + $envTenant = $this->env('TENANT'); + if ($envTenant) { + $tenantData = $this->executeQuery('SELECT * FROM tenants WHERE id = ?', [$envTenant]); + } else { + $request = Request::capture(); + $host = $request->getHost(); + $tenantData = $this->executeQuery('SELECT * FROM tenants WHERE domain = ? LIMIT 1', [$host]); + } + + if (!$tenantData) { + Tenant::setBootstrappedTenant($app, null); + $this->log('No tenant found'); + return; } + $this->setTenantEnvironmentVariables($app, $tenantData); + + $tenantData['original_values'] = $this->getOriginalValue(); + Tenant::setBootstrappedTenant($app, $tenantData); + } + + private function setTenantEnvironmentVariables($app, $tenantData) + { + // Additional configs are set in SwitchTenant.php + + $tenantId = $tenantData['id']; + $config = json_decode($tenantData['config'], true); + + $this->set('APP_CONFIG_CACHE', $app->basePath('storage/tenant_' . $tenantId . '/config.php')); + $this->set('LARAVEL_STORAGE_PATH', $app->basePath('storage/tenant_' . $tenantId)); + $this->set('APP_URL', $config['app.url']); + $this->set('APP_KEY', $this->decrypt($config['app.key'])); + $this->set('DB_DATABASE', $tenantData['database']); + $this->set('DB_USERNAME', $tenantData['username'] ?? $this->getOriginalValue('DB_USERNAME')); + $encryptedPassword = $tenantData['password'] ?? $this->getOriginalValue('DB_PASSWORD'); + $this->set('DB_PASSWORD', $encryptedPassword ? $this->decrypt($encryptedPassword) : $encryptedPassword); + $this->set('REDIS_PREFIX', $this->getOriginalValue('REDIS_PREFIX') . 'tenant-' . $tenantId . ':'); + $this->set('LOG_PATH', $app->basePath('storage/tenant_' . $tenantId . '/logs/processmaker.log')); + } + + private function saveOriginalValues() + { + if ($this->env('ORIGINAL_VALUES')) { + return; + } + $toSave = [ + 'APP_URL', + 'APP_KEY', + 'DB_USERNAME', + 'DB_PASSWORD', + 'REDIS_PREFIX', + 'CACHE_SETTING_PREFIX', + 'SCRIPT_MICROSERVICE_CALLBACK', + ]; + $values = []; + foreach ($toSave as $key) { + $values[$key] = $this->env($key); + } + $this->set('ORIGINAL_VALUES', serialize($values)); + } + + private function getOriginalValue($key = null) + { + if (!$this->originalValues) { + $this->originalValues = unserialize($this->env('ORIGINAL_VALUES')); + } + if (!$key) { + return $this->originalValues; + } + + return $this->originalValues[$key]; + } + + private function env($key, $default = null) + { + return Env::get($key, $default); + } + + private function set($key, $value) + { + Env::getRepository()->set($key, $value); + } + + private function decrypt($value) + { + if (!$this->encrypter) { + $key = $this->getOriginalValue('APP_KEY'); + $landlordKey = base64_decode(substr($key, 7)); + $this->encrypter = new Encrypter($landlordKey, 'AES-256-CBC'); + } - // Get landlord database connection details from environment variables - $landlordConfig = [ - 'host' => Env::get('DB_HOSTNAME', 'localhost'), - 'port' => Env::get('DB_PORT', '3306'), - 'database' => Env::get('LANDLORD_DB_DATABASE', 'landlord'), - 'username' => Env::get('DB_USERNAME'), - 'password' => Env::get('DB_PASSWORD'), + return $this->encrypter->decryptString($value); + } + + private function getLandlordDbConfig(): array + { + return [ + 'host' => $this->env('DB_HOSTNAME', 'localhost'), + 'port' => $this->env('DB_PORT', '3306'), + 'database' => $this->env('LANDLORD_DB_DATABASE', 'landlord'), + 'username' => $this->env('DB_USERNAME'), + 'password' => $this->env('DB_PASSWORD'), 'charset' => 'utf8mb4', ]; + } - try { - // Create PDO connection to landlord database + private function getPdo(): PDO + { + if (!$this->pdo) { + $landlordConfig = $this->getLandlordDbConfig(); $dsn = "mysql:host={$landlordConfig['host']};port={$landlordConfig['port']};dbname={$landlordConfig['database']};charset={$landlordConfig['charset']}"; - $pdo = new PDO($dsn, $landlordConfig['username'], $landlordConfig['password'], [ + $this->pdo = new PDO($dsn, $landlordConfig['username'], $landlordConfig['password'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); - - $tenantData = null; - - // Try to find tenant by ID first if TENANT env var is set - $envTenant = Env::get('TENANT'); - if ($envTenant) { - $stmt = $pdo->prepare('SELECT * FROM tenants WHERE id = ?'); - $stmt->execute([$envTenant]); - $tenantData = $stmt->fetch(); - } else { - $request = Request::capture(); - $host = $request->getHost(); - $stmt = $pdo->prepare('SELECT * FROM tenants WHERE domain = ? LIMIT 1'); - $stmt->execute([$host]); - $tenantData = $stmt->fetch(); - } - - $app->instance(Tenant::BOOTSTRAPPED_TENANT, $tenantData); - } catch (PDOException $e) { - // Log the error but don't throw to avoid breaking the bootstrap process - error_log('TenantBootstrapper Failed: ' . $e->getMessage()); - - return null; } + + return $this->pdo; + } + + private function executeQuery($query, $params = []) + { + $stmt = $this->getPdo()->prepare($query); + $stmt->execute($params); + + return $stmt->fetch(); + } + + private function log($message) + { + $date = date('Y-m-d'); + $log_file = "/Users/nolan/src/processmaker-a/storage/logs/processmaker-{$date}.log"; + file_put_contents($log_file, "Bootstrapper: $message\n", FILE_APPEND); + // echo "Bootstrapper: $message\n"; } } diff --git a/ProcessMaker/Multitenancy/TenantFinder.php b/ProcessMaker/Multitenancy/TenantFinder.php index c85f068728..98c1ba1add 100644 --- a/ProcessMaker/Multitenancy/TenantFinder.php +++ b/ProcessMaker/Multitenancy/TenantFinder.php @@ -12,8 +12,8 @@ class TenantFinder extends DomainTenantFinder { public function findForRequest(Request $request): ?IsTenant { - if (app()->has(Tenant::BOOTSTRAPPED_TENANT)) { - return Tenant::fromBootstrapper(); + if ($tenant = Tenant::fromBootstrapper()) { + return $tenant; } $tenant = null; diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index d86fdbe106..8d151f638c 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -18,7 +18,6 @@ use Illuminate\Support\Facades\URL; use Laravel\Dusk\DuskServiceProvider; use Laravel\Horizon\Horizon; -use Laravel\Passport\Passport; use Lavary\Menu\Menu; use ProcessMaker\Cache\Settings\SettingCacheManager; use ProcessMaker\Console\Migration\ExtendedMigrateCommand; @@ -90,71 +89,6 @@ public function boot(): void Route::pushMiddlewareToGroup('api', HandleEtag::class); // Hook after service providers boot self::$bootTime = (microtime(true) - self::$bootStart) * 1000; // Convert to milliseconds - - // Only run this for console commands so we dont query the Tenants database for each request. - // This sets up individual supervisors for each tenant so that one tenant does not block - // the queue for another tenant. This must be done here instead of SwitchTenant.php because - // there is a single horizon instance for all tenants. - if ($this->app->runningInConsole() && config('app.multitenancy') && $this->horizonTenantsNotSet()) { - $tenants = Tenant::all(); - $config = config('horizon.environments'); - $config = $this->addTenantSupervisors($config, $tenants); - config(['horizon.environments' => $config]); - } - } - - private function horizonTenantsNotSet(): bool - { - $firstKey = array_keys(config('horizon.environments.production'))[0]; - if (str_starts_with($firstKey, 'tenant')) { - // Already cached - return false; - } - - return true; - } - - private function addTenantSupervisors(array $config, TenantCollection $tenants): array - { - $tenantsConfigById = $tenants->mapWithKeys(function ($tenant) { - return [$tenant->id => $tenant->config]; - }); - - foreach ($config as $env => &$supervisors) { - $newSupervisors = []; - - foreach ($supervisors as $supervisorName => $settings) { - foreach ($tenants as $tenant) { - $tenantId = $tenant->id; - $tenantSupervisorName = "tenant-{$tenantId}-{$supervisorName}"; - - // Copy original settings - $tenantSettings = $settings; - - // Set tenant-specific settings - $tenantConfig = $tenantsConfigById[$tenantId]; - foreach (['balance', 'tries', 'timeout', 'minProcesses', 'maxProcesses'] as $key) { - $value = Arr::get($tenantConfig, "horizon.{$supervisorName}.{$key}", null); - if ($value !== null) { - $tenantSettings[$key] = $value; - } - } - - // Prepend tenant ID to each queue - if (isset($tenantSettings['queue']) && is_array($tenantSettings['queue'])) { - $tenantSettings['queue'] = array_map(function ($queue) use ($tenantId) { - return "tenant-{$tenantId}-{$queue}"; - }, $tenantSettings['queue']); - } - - $newSupervisors[$tenantSupervisorName] = $tenantSettings; - } - } - - $supervisors = $newSupervisors; - } - - return $config; } public function register(): void @@ -259,6 +193,10 @@ public function register(): void // Miscellaneous vendor customization static::configureVendors(); + $this->app->singleton(PackageManifest::class, fn () => new LicensedPackageManifest( + new Filesystem, $this->app->basePath(), $this->app->getCachedPackagesPath() + )); + $this->app->extend(MigrateCommand::class, function () { return new ExtendedMigrateCommand( app('migrator'), @@ -587,8 +525,7 @@ private function setCurrentTenantForConsoleCommands(): void return; } - if (app()->has(Tenant::BOOTSTRAPPED_TENANT)) { - $tenant = Tenant::fromBootstrapper(); + if ($tenant = Tenant::fromBootstrapper()) { $tenant->makeCurrent(); return; diff --git a/config/logging.php b/config/logging.php index 8f68eeced3..075376e46c 100644 --- a/config/logging.php +++ b/config/logging.php @@ -62,14 +62,14 @@ 'single' => [ 'driver' => 'single', - 'path' => base_path('storage/logs/processmaker.log'), + 'path' => env('LOG_PATH', base_path('storage/logs/processmaker.log')), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'daily' => [ 'driver' => 'daily', - 'path' => base_path('storage/logs/processmaker.log'), + 'path' => env('LOG_PATH', base_path('storage/logs/processmaker.log')), 'level' => env('LOG_LEVEL', 'debug'), 'days' => 7, 'replace_placeholders' => true, From 820086c482fd2ac6a18f7faee79146ec65e284fe Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Thu, 25 Sep 2025 17:49:49 -0700 Subject: [PATCH 05/17] Add the application updates for multitenancy --- ProcessMaker/Application.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ProcessMaker/Application.php b/ProcessMaker/Application.php index 38e37d4aff..b459358dd3 100644 --- a/ProcessMaker/Application.php +++ b/ProcessMaker/Application.php @@ -6,8 +6,13 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application as IlluminateApplication; use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; +use Illuminate\Foundation\Bootstrap\RegisterProviders; use Illuminate\Foundation\PackageManifest; +use Illuminate\Support\Env; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Config; +use ProcessMaker\Multitenancy\Tenant; use ProcessMaker\Multitenancy\TenantBootstrapper; /** @@ -15,6 +20,10 @@ */ class Application extends IlluminateApplication { + public $overrideTenantId = null; + + public $skipCacheEvents = false; + /** * Sets the timezone for the application and for php with the specified timezone. * @@ -96,10 +105,10 @@ public function registerConfiguredProviders() public function bootstrapWith(array $bootstrappers) { // Insert TenantBootstrapper after LoadEnvironmentVariables - $index = array_search(LoadEnvironmentVariables::class, $bootstrappers); - if ($index !== false) { - array_splice($bootstrappers, $index + 1, 0, [TenantBootstrapper::class]); + if ($bootstrappers[0] !== LoadEnvironmentVariables::class) { + throw new \Exception('LoadEnvironmentVariables is not the first bootstrapper. Did a laravel upgrade change this?'); } + array_splice($bootstrappers, 1, 0, [TenantBootstrapper::class]); return parent::bootstrapWith($bootstrappers); } From 7bd37130b760d20bb744d3e94a0abdbeca1828e2 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 26 Sep 2025 10:03:26 -0700 Subject: [PATCH 06/17] Fix for non-multitenant instances --- .../Console/Commands/TenantsVerify.php | 35 ++++++++++--------- ProcessMaker/Multitenancy/TenantFinder.php | 4 +++ .../Providers/ProcessMakerServiceProvider.php | 18 ++++++---- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsVerify.php b/ProcessMaker/Console/Commands/TenantsVerify.php index 07740f6bb9..6f5bb67e85 100644 --- a/ProcessMaker/Console/Commands/TenantsVerify.php +++ b/ProcessMaker/Console/Commands/TenantsVerify.php @@ -3,7 +3,9 @@ namespace ProcessMaker\Console\Commands; use Illuminate\Console\Command; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use ProcessMaker\Models\EnvironmentVariable; @@ -26,17 +28,6 @@ class TenantsVerify extends Command */ protected $description = 'Verify tenant configuration and storage paths'; - /** - * Strip protocol from URL - * - * @param string $url - * @return string - */ - private function stripProtocol(string $url): string - { - return preg_replace('#^https?://#', '', $url); - } - /** * Execute the console command. * @@ -49,13 +40,13 @@ public function handle() $currentTenant = app('currentTenant'); } - if (!$currentTenant) { - $this->error('No current tenant found'); + if (config('app.multitenancy') && !$currentTenant) { + $this->error('Multitenancy enabled but current tenant found.'); return; } - $this->info('Current Tenant ID: ' . $currentTenant->id); + $this->info('Current Tenant ID: ' . ($currentTenant?->id ?? 'NONE')); $paths = [ ['Storage Path', storage_path()], @@ -88,14 +79,26 @@ public function handle() // Display configs in a nice table $this->table(['Config', 'Value'], $configs); + $env = EnvironmentVariable::first(); + if (!$env) { + $decrypted = 'No environment variables found to test decryption'; + } + $encryptedValue = $env->getAttributes()['value']; + try { + Crypt::decryptString($encryptedValue); + $decrypted = 'OK'; + } catch (DecryptException $e) { + $decrypted = 'FAILED! ' . $e->getMessage(); + } + $other = [ ['Landlord Config Cache Path', base_path('bootstrap/cache/config.php')], ['Landlord Config Is Cached', File::exists(base_path('bootstrap/cache/config.php')) ? 'Yes' : 'No'], ['Tenant Config Cache Path', app()->getCachedConfigPath()], ['Tenant Config Is Cached', File::exists(app()->getCachedConfigPath()) ? 'Yes' : 'No'], ['First username (database check)', User::first()->username], - ['First environment variable (decrypted check)', substr(EnvironmentVariable::first()->value, 0, 15)], - ['Original App URL (landlord)', $currentTenant->getOriginalValue('APP_URL')], + ['Decrypted check', substr($decrypted, 0, 50)], + ['Original App URL (landlord)', $currentTenant?->getOriginalValue('APP_URL') ?? config('app.url')], ]; // Display other in a nice table diff --git a/ProcessMaker/Multitenancy/TenantFinder.php b/ProcessMaker/Multitenancy/TenantFinder.php index 98c1ba1add..5dcbb801f9 100644 --- a/ProcessMaker/Multitenancy/TenantFinder.php +++ b/ProcessMaker/Multitenancy/TenantFinder.php @@ -12,6 +12,10 @@ class TenantFinder extends DomainTenantFinder { public function findForRequest(Request $request): ?IsTenant { + if (!config('app.multitenancy')) { + return null; + } + if ($tenant = Tenant::fromBootstrapper()) { return $tenant; } diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 8d151f638c..33f3871fac 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -525,6 +525,12 @@ private function setCurrentTenantForConsoleCommands(): void return; } + if (config('app.multitenancy') === false) { + event(new TenantResolved(null)); + + return; + } + if ($tenant = Tenant::fromBootstrapper()) { $tenant->makeCurrent(); @@ -532,14 +538,12 @@ private function setCurrentTenantForConsoleCommands(): void } $tenantId = Env::get('TENANT'); - if ($tenantId) { - $tenant = Tenant::findOrFail($tenantId); - $tenant->makeCurrent(); - } elseif (config('app.multitenancy') === false) { - // This is expected if multitenancy is disabled. - // Call the TenantResolved event with null to continue loading the app. - event(new TenantResolved(null)); + if (!$tenantId) { + throw new \Exception('Multitenancy is enabled but no tenant ID was found in the environment.'); } + + $tenant = Tenant::findOrFail($tenantId); + $tenant->makeCurrent(); } /** From 8ae49b89ca9147fdb5c0dddb7aa564ff2fe95d9b Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 26 Sep 2025 11:56:32 -0700 Subject: [PATCH 07/17] Remove debugging --- ProcessMaker/Multitenancy/TenantBootstrapper.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php index 25873620bf..d4c8c0bfab 100644 --- a/ProcessMaker/Multitenancy/TenantBootstrapper.php +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -46,7 +46,6 @@ public function bootstrap(Application $app) if (!$tenantData) { Tenant::setBootstrappedTenant($app, null); - $this->log('No tenant found'); return; } @@ -162,12 +161,4 @@ private function executeQuery($query, $params = []) return $stmt->fetch(); } - - private function log($message) - { - $date = date('Y-m-d'); - $log_file = "/Users/nolan/src/processmaker-a/storage/logs/processmaker-{$date}.log"; - file_put_contents($log_file, "Bootstrapper: $message\n", FILE_APPEND); - // echo "Bootstrapper: $message\n"; - } } From af7b2f6eb7198a420fab3362b0cfa7bb6fedf430 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 26 Sep 2025 13:52:33 -0700 Subject: [PATCH 08/17] Fix --- ProcessMaker/Providers/ProcessMakerServiceProvider.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 33f3871fac..fd93a0b222 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -539,7 +539,9 @@ private function setCurrentTenantForConsoleCommands(): void $tenantId = Env::get('TENANT'); if (!$tenantId) { - throw new \Exception('Multitenancy is enabled but no tenant ID was found in the environment.'); + event(new TenantResolved(null)); + + return; } $tenant = Tenant::findOrFail($tenantId); From 6c71af77d7f494da890386d5f6460641ff84e95d Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Fri, 26 Sep 2025 14:35:30 -0700 Subject: [PATCH 09/17] Fix MT decrypt password --- ProcessMaker/Multitenancy/TenantBootstrapper.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php index d4c8c0bfab..7b21c02191 100644 --- a/ProcessMaker/Multitenancy/TenantBootstrapper.php +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -68,8 +68,16 @@ private function setTenantEnvironmentVariables($app, $tenantData) $this->set('APP_KEY', $this->decrypt($config['app.key'])); $this->set('DB_DATABASE', $tenantData['database']); $this->set('DB_USERNAME', $tenantData['username'] ?? $this->getOriginalValue('DB_USERNAME')); - $encryptedPassword = $tenantData['password'] ?? $this->getOriginalValue('DB_PASSWORD'); - $this->set('DB_PASSWORD', $encryptedPassword ? $this->decrypt($encryptedPassword) : $encryptedPassword); + + $encryptedPassword = $tenantData['password']; + $password = null; + if ($encryptedPassword) { + $password = $this->decrypt($encryptedPassword); + } else { + $password = $this->getOriginalValue('DB_PASSWORD'); + } + + $this->set('DB_PASSWORD', $password); $this->set('REDIS_PREFIX', $this->getOriginalValue('REDIS_PREFIX') . 'tenant-' . $tenantId . ':'); $this->set('LOG_PATH', $app->basePath('storage/tenant_' . $tenantId . '/logs/processmaker.log')); } From 55675a1912eb7b9edce7115974b17a52bdd74608 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Mon, 29 Sep 2025 12:22:18 -0700 Subject: [PATCH 10/17] Revert "Use a dedicated non-multitenant disk for license.json" This reverts commit 34511995e05d1a604ee8008d93ae9b78cf0f128c. --- .../Commands/ProcessMakerLicenseRemove.php | 4 ++-- .../Commands/ProcessMakerLicenseUpdate.php | 2 +- ProcessMaker/LicensedPackageManifest.php | 6 +++--- config/filesystems.php | 7 ------- tests/Feature/LicenseCommandsTest.php | 16 ++++++++-------- tests/Feature/LicenseTest.php | 14 +++++++------- tests/Feature/MediaConfigTest.php | 2 +- tests/Model/DevLinkTest.php | 4 ++-- 8 files changed, 24 insertions(+), 31 deletions(-) diff --git a/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php b/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php index 2918b4eb83..fd01a81ef8 100644 --- a/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php +++ b/ProcessMaker/Console/Commands/ProcessMakerLicenseRemove.php @@ -34,9 +34,9 @@ class ProcessMakerLicenseRemove extends Command */ public function handle() { - if (Storage::disk('root')->exists('license.json')) { + if (Storage::disk('local')->exists('license.json')) { if ($this->option('force') || $this->confirm('Are you sure you want to remove the license.json file?')) { - Storage::disk('root')->delete('license.json'); + Storage::disk('local')->delete('license.json'); $this->info('license.json removed successfully!'); $this->info('Calling package:discover to update the package cache with enabled packages'); diff --git a/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php b/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php index cb6a3bce10..83c1a72a5d 100644 --- a/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php +++ b/ProcessMaker/Console/Commands/ProcessMakerLicenseUpdate.php @@ -40,7 +40,7 @@ public function handle() return 1; } - Storage::disk('root')->put('license.json', $content); + Storage::disk('local')->put('license.json', $content); $this->info('Calling package:discover to update the package cache with enabled packages'); Artisan::call('package:discover'); diff --git a/ProcessMaker/LicensedPackageManifest.php b/ProcessMaker/LicensedPackageManifest.php index 94048a176e..cadff0791a 100644 --- a/ProcessMaker/LicensedPackageManifest.php +++ b/ProcessMaker/LicensedPackageManifest.php @@ -63,14 +63,14 @@ private function parseLicense() if (!$this->hasLicenseFile()) { return null; } - $license = Storage::disk('root')->get('license.json'); + $license = Storage::disk('local')->get('license.json'); return json_decode($license, true); } private function licensedPackages() { - $default = collect(['packages', 'package-api-testing']); + $default = collect(['packages','package-api-testing']); $data = $this->parseLicense(); $expires = Carbon::parse($data['expires_at']); if ($expires->isPast()) { @@ -84,7 +84,7 @@ private function licensedPackages() private function hasLicenseFile() { - return Storage::disk('root')->exists('license.json'); + return Storage::disk('local')->exists('license.json'); } private function setExpireCache() diff --git a/config/filesystems.php b/config/filesystems.php index e79015155f..ba60def6ff 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -126,13 +126,6 @@ 'root' => lang_path(), ], - // Note, this storage path is for all tenants. It is not modififed in SwitchTenant.php - // Used for license.json since, for now, its the same for all tenants - 'root' => [ - 'driver' => 'local', - 'root' => storage_path(), - ], - // Others declared in packages // - translations - package-translations // - 'filesystems.disks.install' configured on the fly diff --git a/tests/Feature/LicenseCommandsTest.php b/tests/Feature/LicenseCommandsTest.php index 171ba2fb09..5e43b77939 100644 --- a/tests/Feature/LicenseCommandsTest.php +++ b/tests/Feature/LicenseCommandsTest.php @@ -17,14 +17,14 @@ class LicenseCommandsTest extends TestCase protected function setUp(): void { parent::setUp(); - Storage::fake('root'); + Storage::fake('local'); } protected function tearDown(): void { // remove the license.json file if it exists - if (Storage::disk('root')->exists('license.json')) { - Storage::disk('root')->delete('license.json'); + if (Storage::disk('local')->exists('license.json')) { + Storage::disk('local')->delete('license.json'); } Cache::forget(LicensedPackageManifest::EXPIRE_CACHE_KEY); // run package:discover @@ -43,7 +43,7 @@ public function testLicenseUpdateFromLocalPath() $this->artisan('processmaker:license-update', ['licenseFile' => $licenseFilePath]) ->assertExitCode(0); - $this->assertTrue(Storage::disk('root')->exists('license.json')); + $this->assertTrue(Storage::disk('local')->exists('license.json')); } public function testLicenseUpdateWithInvalidContent() @@ -59,26 +59,26 @@ public function testLicenseUpdateWithInvalidContent() public function testLicenseRemoveConfirmation() { - Storage::disk('root')->put('license.json', 'sample content'); + Storage::disk('local')->put('license.json', 'sample content'); $this->artisan('processmaker:license-remove') ->expectsQuestion('Are you sure you want to remove the license.json file?', false) ->expectsOutput('Operation cancelled. license.json was not removed.') ->assertExitCode(0); - $this->assertTrue(Storage::disk('root')->exists('license.json')); + $this->assertTrue(Storage::disk('local')->exists('license.json')); } public function testLicenseRemove() { - Storage::disk('root')->put('license.json', '{"expires_at": "2023-12-31", "packages": []}'); + Storage::disk('local')->put('license.json', '{"expires_at": "2023-12-31", "packages": []}'); $this->artisan('processmaker:license-remove') ->expectsQuestion('Are you sure you want to remove the license.json file?', true) ->expectsOutput('license.json removed successfully!') ->assertExitCode(0); - $this->assertFalse(Storage::disk('root')->exists('license.json')); + $this->assertFalse(Storage::disk('local')->exists('license.json')); } public function testLicenseRemoveNonExistent() diff --git a/tests/Feature/LicenseTest.php b/tests/Feature/LicenseTest.php index abf6038557..dd67013ef2 100644 --- a/tests/Feature/LicenseTest.php +++ b/tests/Feature/LicenseTest.php @@ -24,14 +24,14 @@ class LicenseTest extends TestCase protected function setUp(): void { parent::setUp(); - Storage::fake('root'); + Storage::fake('local'); } protected function tearDown(): void { // remove the license.json file if it exists - if (Storage::disk('root')->exists('license.json')) { - Storage::disk('root')->delete('license.json'); + if (Storage::disk('local')->exists('license.json')) { + Storage::disk('local')->delete('license.json'); } Cache::forget(LicensedPackageManifest::EXPIRE_CACHE_KEY); @@ -54,7 +54,7 @@ public function testLicense() ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); $packageManifest = $this->app->make(PackageManifest::class); $packagesToIgnore = $packageManifest->loadPackagesToIgnore(); @@ -84,7 +84,7 @@ public function testExpiredLicense() ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); Carbon::setTestNow(Carbon::now()->addDays(31)); @@ -118,7 +118,7 @@ public function testProviderWithLicense() 'package-projects', ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); Artisan::call('package:discover'); @@ -141,7 +141,7 @@ public function testProviderWithExpiredLicense() 'package-projects', ], ]; - Storage::disk('root')->put('license.json', json_encode($license)); + Storage::disk('local')->put('license.json', json_encode($license)); Artisan::call('package:discover'); diff --git a/tests/Feature/MediaConfigTest.php b/tests/Feature/MediaConfigTest.php index 1f9496c5d6..abb864d126 100644 --- a/tests/Feature/MediaConfigTest.php +++ b/tests/Feature/MediaConfigTest.php @@ -23,7 +23,7 @@ public function testMediaMaxFileSize() $user = $processRequest->user; // Ensure storage disk is available - Storage::fake('root'); + Storage::fake('local'); // Test file within size limit (500KB) $validFilePath = storage_path('app/test_valid.txt'); diff --git a/tests/Model/DevLinkTest.php b/tests/Model/DevLinkTest.php index c01bfe0a52..4fdd994baa 100644 --- a/tests/Model/DevLinkTest.php +++ b/tests/Model/DevLinkTest.php @@ -57,7 +57,7 @@ public function testGetOauthRedirectUrl() public function testInstallRemoteBundle() { - Storage::fake('root'); + Storage::fake('local'); $screen1 = Screen::factory()->create(['title' => 'Screen 1']); $screen2 = Screen::factory()->create(['title' => 'Screen 2']); @@ -152,7 +152,7 @@ public function testRemoteBundles() public function testUpdateBundle() { - Storage::fake('root'); + Storage::fake('local'); // Remote Instance $screen = Screen::factory()->create(['title' => 'Screen Name']); From 2df438d10cefb85c00a0fc75700fdc445953af0a Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Mon, 29 Sep 2025 12:31:54 -0700 Subject: [PATCH 11/17] Move lang setting to bootstrapper so we can use it in filesystems.php --- ProcessMaker/Console/Commands/TenantsVerify.php | 3 +++ ProcessMaker/Multitenancy/SwitchTenant.php | 3 --- ProcessMaker/Multitenancy/TenantBootstrapper.php | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsVerify.php b/ProcessMaker/Console/Commands/TenantsVerify.php index 6f5bb67e85..bdf873357d 100644 --- a/ProcessMaker/Console/Commands/TenantsVerify.php +++ b/ProcessMaker/Console/Commands/TenantsVerify.php @@ -67,6 +67,9 @@ public function handle() 'script-runner-microservice.callback', 'database.connections.processmaker.database', 'logging.channels.daily.path', + 'filesystems.disks.public.root', + 'filesystems.disks.local.root', + 'filesystems.disks.lang.root', ]; $configs = array_map(function ($config) { diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index 06ba0e0ab9..1902a0b8e5 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -28,9 +28,6 @@ public function makeCurrent(IsTenant $tenant): void // Set the tenant's domain in the request headers. Used for things like the global url() helper. request()->headers->set('host', $tenant->domain); - // Use tenant's translation files - $app->useLangPath(resource_path('lang/tenant_' . $tenant->id)); - $this->overrideConfigs($app, $tenant); // Extend BroadcastManager to our custom implementation that prefixes the channel names with the tenant id. diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php index 7b21c02191..acd5fd9225 100644 --- a/ProcessMaker/Multitenancy/TenantBootstrapper.php +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -51,6 +51,9 @@ public function bootstrap(Application $app) } $this->setTenantEnvironmentVariables($app, $tenantData); + // Use tenant's translation files. Doing this here so it's available in cached filesystems.php + $app->useLangPath(resource_path('lang/tenant_' . $tenantData['id'])); + $tenantData['original_values'] = $this->getOriginalValue(); Tenant::setBootstrappedTenant($app, $tenantData); } From ad06e80953ebaa2b21d6e1a1ab78d43f34c211c3 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Tue, 30 Sep 2025 08:01:17 -0700 Subject: [PATCH 12/17] Fix test --- ProcessMaker/Jobs/RefreshArtisanCaches.php | 7 +++++++ tests/TestCase.php | 12 ------------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/ProcessMaker/Jobs/RefreshArtisanCaches.php b/ProcessMaker/Jobs/RefreshArtisanCaches.php index 0ca44566dc..930441c631 100644 --- a/ProcessMaker/Jobs/RefreshArtisanCaches.php +++ b/ProcessMaker/Jobs/RefreshArtisanCaches.php @@ -42,6 +42,13 @@ public function middleware(): array */ public function handle() { + // Skip in testing environment because this reconnects the database + // meaning we loose transactions, and sets the console output verbosity + // to quiet so we loose expectsOutput assertions. + if (app()->environment('testing')) { + return; + } + $options = [ '--no-interaction' => true, '--quiet' => true, diff --git a/tests/TestCase.php b/tests/TestCase.php index 1e8be54cbd..33c1b10809 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -134,18 +134,6 @@ public function setUpMockScriptRunners(): void config()->set('script-runners.php-nayra.runner', 'MockRunner'); } - /** - * Calling the real config:cache command reconnects the database - * and since we're using transactions for our tests, we lose any data - * saved before the command is run. Instead, mock it out here. - */ - public function setUpMockConfigCache(): void - { - Bus::fake([ - RefreshArtisanCaches::class, - ]); - } - /** * Run additional tearDowns from traits. */ From 6f7e70830838071334e1567ce500cb31164e0b1f Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Tue, 30 Sep 2025 12:59:59 -0700 Subject: [PATCH 13/17] Remove custom provider ordering --- ProcessMaker/LicensedPackageManifest.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/ProcessMaker/LicensedPackageManifest.php b/ProcessMaker/LicensedPackageManifest.php index cadff0791a..cac7ca6243 100644 --- a/ProcessMaker/LicensedPackageManifest.php +++ b/ProcessMaker/LicensedPackageManifest.php @@ -22,15 +22,6 @@ class LicensedPackageManifest extends PackageManifest const LAST_PACKAGE_DISCOVERY = 0; - public function providers() - { - $providers = parent::providers(); - array_unshift($providers, ProcessMakerServiceProvider::class); - array_unshift($providers, MultitenancyServiceProvider::class); - - return $providers; - } - protected function packagesToIgnore() { $packagesToIgnore = $this->loadPackagesToIgnore()->all(); @@ -70,7 +61,7 @@ private function parseLicense() private function licensedPackages() { - $default = collect(['packages','package-api-testing']); + $default = collect(['packages', 'package-api-testing']); $data = $this->parseLicense(); $expires = Carbon::parse($data['expires_at']); if ($expires->isPast()) { From bc9afa0c4b03bc32f9fb1567f47d99dbb738278b Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Tue, 30 Sep 2025 15:11:33 -0700 Subject: [PATCH 14/17] Fixes for cursorbot --- .../Console/Commands/TenantsVerify.php | 19 ++++++++++--------- ProcessMaker/Multitenancy/Tenant.php | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/ProcessMaker/Console/Commands/TenantsVerify.php b/ProcessMaker/Console/Commands/TenantsVerify.php index bdf873357d..36ff3b01ac 100644 --- a/ProcessMaker/Console/Commands/TenantsVerify.php +++ b/ProcessMaker/Console/Commands/TenantsVerify.php @@ -41,7 +41,7 @@ public function handle() } if (config('app.multitenancy') && !$currentTenant) { - $this->error('Multitenancy enabled but current tenant found.'); + $this->error('Multitenancy enabled but no current tenant found.'); return; } @@ -85,13 +85,14 @@ public function handle() $env = EnvironmentVariable::first(); if (!$env) { $decrypted = 'No environment variables found to test decryption'; - } - $encryptedValue = $env->getAttributes()['value']; - try { - Crypt::decryptString($encryptedValue); - $decrypted = 'OK'; - } catch (DecryptException $e) { - $decrypted = 'FAILED! ' . $e->getMessage(); + } else { + $encryptedValue = $env->getAttributes()['value']; + try { + Crypt::decryptString($encryptedValue); + $decrypted = 'OK'; + } catch (DecryptException $e) { + $decrypted = 'FAILED! ' . $e->getMessage(); + } } $other = [ @@ -99,7 +100,7 @@ public function handle() ['Landlord Config Is Cached', File::exists(base_path('bootstrap/cache/config.php')) ? 'Yes' : 'No'], ['Tenant Config Cache Path', app()->getCachedConfigPath()], ['Tenant Config Is Cached', File::exists(app()->getCachedConfigPath()) ? 'Yes' : 'No'], - ['First username (database check)', User::first()->username], + ['First username (database check)', User::first()?->username ?? 'No users found'], ['Decrypted check', substr($decrypted, 0, 50)], ['Original App URL (landlord)', $currentTenant?->getOriginalValue('APP_URL') ?? config('app.url')], ]; diff --git a/ProcessMaker/Multitenancy/Tenant.php b/ProcessMaker/Multitenancy/Tenant.php index 5eca3b79d7..4b2b9f091a 100644 --- a/ProcessMaker/Multitenancy/Tenant.php +++ b/ProcessMaker/Multitenancy/Tenant.php @@ -39,6 +39,6 @@ public static function fromBootstrapper() public function getOriginalValue($key = null) { - return $this->originalValues[$key]; + return $this->originalValues[$key] ?? null; } } From 6a2dc1043979a5d926d1e9c42afdd1a36b9ecac8 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Wed, 1 Oct 2025 11:26:05 -0700 Subject: [PATCH 15/17] Do not override package cache path yet --- ProcessMaker/Multitenancy/TenantBootstrapper.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php index acd5fd9225..8e874a8da4 100644 --- a/ProcessMaker/Multitenancy/TenantBootstrapper.php +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -66,6 +66,8 @@ private function setTenantEnvironmentVariables($app, $tenantData) $config = json_decode($tenantData['config'], true); $this->set('APP_CONFIG_CACHE', $app->basePath('storage/tenant_' . $tenantId . '/config.php')); + // Do not override packages cache path for now. Wait until the License service is updated. + // $this->set('APP_PACKAGES_CACHE', $app->basePath('storage/tenant_' . $tenantId . '/packages.php')); $this->set('LARAVEL_STORAGE_PATH', $app->basePath('storage/tenant_' . $tenantId)); $this->set('APP_URL', $config['app.url']); $this->set('APP_KEY', $this->decrypt($config['app.key'])); From 2a54a6cc0b7e325ab8860da468f3d76ba92e1836 Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Thu, 2 Oct 2025 08:37:12 -0700 Subject: [PATCH 16/17] Debounce refresh artisan caches --- ProcessMaker/Jobs/RefreshArtisanCaches.php | 28 +++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/ProcessMaker/Jobs/RefreshArtisanCaches.php b/ProcessMaker/Jobs/RefreshArtisanCaches.php index 930441c631..e027a4cf70 100644 --- a/ProcessMaker/Jobs/RefreshArtisanCaches.php +++ b/ProcessMaker/Jobs/RefreshArtisanCaches.php @@ -3,17 +3,20 @@ namespace ProcessMaker\Jobs; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Support\Facades\Artisan; -class RefreshArtisanCaches implements ShouldQueue +class RefreshArtisanCaches implements ShouldQueue, ShouldBeUnique { use Dispatchable, InteractsWithQueue, Queueable; - public $tries = 1; + public $tries = 2; // One extra try to handle the debounce release + + public $queuedAt; /** * Create a new job instance. @@ -22,7 +25,7 @@ class RefreshArtisanCaches implements ShouldQueue */ public function __construct() { - // + $this->queuedAt = time(); } /** @@ -32,7 +35,9 @@ public function __construct() */ public function middleware(): array { - return [(new WithoutOverlapping('refresh_artisan_caches'))->dontRelease()]; + return [ + (new WithoutOverlapping('refresh_artisan_caches'))->dontRelease(), + ]; } /** @@ -42,10 +47,10 @@ public function middleware(): array */ public function handle() { - // Skip in testing environment because this reconnects the database - // meaning we loose transactions, and sets the console output verbosity - // to quiet so we loose expectsOutput assertions. - if (app()->environment('testing')) { + // Wait 3 seconds before running the job - debounce + if ($this->queuedAt && $this->queuedAt >= time() - 3) { + $this->release(3); + return; } @@ -56,7 +61,12 @@ public function handle() if (app()->configurationIsCached()) { Artisan::call('config:cache', $options); + } else { + Artisan::call('queue:restart', $options); + + // We call this manually here since this job is dispatched + // automatically when the config *is* cached + RestartMessageConsumers::dispatchSync(); } - Artisan::call('queue:restart', $options); } } From 3fc107cd96c305e551565e393959e0bece1763cd Mon Sep 17 00:00:00 2001 From: Nolan Ehrstrom Date: Thu, 2 Oct 2025 09:32:25 -0700 Subject: [PATCH 17/17] Fix missing return from conflict --- ProcessMaker/Jobs/RefreshArtisanCaches.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ProcessMaker/Jobs/RefreshArtisanCaches.php b/ProcessMaker/Jobs/RefreshArtisanCaches.php index cc9bac4f7f..6aeb31e5e0 100644 --- a/ProcessMaker/Jobs/RefreshArtisanCaches.php +++ b/ProcessMaker/Jobs/RefreshArtisanCaches.php @@ -57,6 +57,8 @@ public function handle() // Wait 3 seconds before running the job - debounce if ($this->queuedAt && $this->queuedAt >= time() - 3) { $this->release(3); + + return; } $options = [