diff --git a/ProcessMaker/Application.php b/ProcessMaker/Application.php index 023fc26c4f..b459358dd3 100644 --- a/ProcessMaker/Application.php +++ b/ProcessMaker/Application.php @@ -5,14 +5,25 @@ use Igaster\LaravelTheme\Facades\Theme; 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; /** * Class Application. */ class Application extends IlluminateApplication { + public $overrideTenantId = null; + + public $skipCacheEvents = false; + /** * Sets the timezone for the application and for php with the specified timezone. * @@ -90,4 +101,15 @@ public function registerConfiguredProviders() parent::registerConfiguredProviders(); } + + public function bootstrapWith(array $bootstrappers) + { + // Insert TenantBootstrapper after LoadEnvironmentVariables + 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); + } } diff --git a/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php b/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php index c8a821fa14..fbc970c83f 100644 --- a/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php +++ b/ProcessMaker/AssignmentRules/ProcessManagerAssigned.php @@ -6,6 +6,8 @@ use ProcessMaker\Exception\ThereIsNoProcessManagerAssignedException; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\User; use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Nayra\Contracts\Bpmn\TokenInterface; @@ -24,16 +26,62 @@ class ProcessManagerAssigned implements AssignmentRuleInterface * @param TokenInterface $token * @param Process $process * @param ProcessRequest $request - * @return int + * @return int|null * @throws ThereIsNoProcessManagerAssignedException */ public function getNextUser(ActivityInterface $task, TokenInterface $token, Process $process, ProcessRequest $request) { - $user_id = $request->processVersion->manager_id; + // review for multiple managers + $managers = $request->processVersion->manager_id; + $user_id = $this->getNextManagerAssigned($managers, $task, $request); if (!$user_id) { throw new ThereIsNoProcessManagerAssignedException($task); } return $user_id; } + + /** + * Get the round robin manager using a true round robin algorithm + * + * @param array $managers + * @param ActivityInterface $task + * @param ProcessRequest $request + * @return int|null + */ + private function getNextManagerAssigned($managers, $task, $request) + { + // Validate input + if (empty($managers) || !is_array($managers)) { + return null; + } + + // If only one manager, return it + if (count($managers) === 1) { + return $managers[0]; + } + + // get the last manager assigned to the task across all requests + $last = ProcessRequestToken::where('process_id', $request->process_id) + ->where('element_id', $task->getId()) + ->whereIn('user_id', $managers) + ->orderBy('created_at', 'desc') + ->first(); + + $user_id = $last ? $last->user_id : null; + + sort($managers); + + $key = array_search($user_id, $managers); + if ($key === false) { + // If no previous manager found, start with the first manager + $key = 0; + } else { + // Move to the next manager in the round-robin + $key = ($key + 1) % count($managers); + } + $user_id = $managers[$key]; + + return $user_id; + } } 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/Console/Commands/TenantsCreate.php b/ProcessMaker/Console/Commands/TenantsCreate.php index 6048bc68bc..43635aea58 100644 --- a/ProcessMaker/Console/Commands/TenantsCreate.php +++ b/ProcessMaker/Console/Commands/TenantsCreate.php @@ -198,7 +198,9 @@ public function handle() $this->line('- Run migrations and seed the database'); $this->line('- Run the install command for each package'); $this->line('- Run artisan upgrade'); - $this->line('- Install passport by calling passport:install'); + $this->line('- Install passport by calling passport:install (create the default clients'); + $this->line('- Reset the admin password with auth:set-password'); + $this->line('- Run processmaker:initialize-script-microservice'); $this->info("For example, `TENANT={$tenant->id} php artisan migrate:fresh --seed`"); } } diff --git a/ProcessMaker/Console/Commands/TenantsList.php b/ProcessMaker/Console/Commands/TenantsList.php index 72b24b599d..0971536155 100644 --- a/ProcessMaker/Console/Commands/TenantsList.php +++ b/ProcessMaker/Console/Commands/TenantsList.php @@ -15,7 +15,7 @@ class TenantsList extends Command * * @var string */ - protected $signature = 'tenants:list {--ids : Only output the ids}'; + protected $signature = 'tenants:list {--ids : Only output the ids} {--json : Output the tenants as JSON}'; /** * The console command description. @@ -40,6 +40,12 @@ public function handle() return; } + if ($this->option('json')) { + $this->line(json_encode($tenants->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return; + } + $formattedTenants = $tenants->map(function ($tenant) { $config = $tenant->config; diff --git a/ProcessMaker/Console/Commands/TenantsVerify.php b/ProcessMaker/Console/Commands/TenantsVerify.php index c5294012fc..36ff3b01ac 100644 --- a/ProcessMaker/Console/Commands/TenantsVerify.php +++ b/ProcessMaker/Console/Commands/TenantsVerify.php @@ -3,8 +3,13 @@ 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; +use ProcessMaker\Models\User; use Spatie\Multitenancy\Models\Tenant; class TenantsVerify extends Command @@ -14,7 +19,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. @@ -23,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. * @@ -46,85 +40,72 @@ public function handle() $currentTenant = app('currentTenant'); } - $verifyAgainstId = $this->option('verify-against'); - - if (!$currentTenant) { - $this->error('No current tenant found'); + if (config('app.multitenancy') && !$currentTenant) { + $this->error('Multitenancy enabled but no current tenant found.'); return; } - $this->info('Current Tenant ID: ' . $currentTenant->id); - $this->line('----------------------------------------'); + $this->info('Current Tenant ID: ' . ($currentTenant?->id ?? 'NONE')); - // 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'), + $paths = [ + ['Storage Path', storage_path()], + ['Config Cache Path', app()->getCachedConfigPath()], + ['Lang Path', lang_path()], ]; - // 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}"); - } - - // 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', + 'filesystems.disks.public.root', + 'filesystems.disks.local.root', + 'filesystems.disks.lang.root', + ]; - $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), ]; - - $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; - } + }, $configs); + + // Display configs in a nice table + $this->table(['Config', 'Value'], $configs); + + $env = EnvironmentVariable::first(); + if (!$env) { + $decrypted = 'No environment variables found to test decryption'; + } else { + $encryptedValue = $env->getAttributes()['value']; + try { + Crypt::decryptString($encryptedValue); + $decrypted = 'OK'; + } catch (DecryptException $e) { + $decrypted = 'FAILED! ' . $e->getMessage(); } - - if (!$hasMismatch) { - $this->info('All configurations match as expected!'); - } - - return $hasMismatch ? Command::FAILURE : Command::SUCCESS; } - return Command::SUCCESS; + $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 ?? 'No users found'], + ['Decrypted check', substr($decrypted, 0, 50)], + ['Original App URL (landlord)', $currentTenant?->getOriginalValue('APP_URL') ?? config('app.url')], + ]; + + // Display other in a nice table + $this->table(['Other', 'Value'], $other); } } diff --git a/ProcessMaker/Console/Kernel.php b/ProcessMaker/Console/Kernel.php index d5f52d15e1..1d6ee38a81 100644 --- a/ProcessMaker/Console/Kernel.php +++ b/ProcessMaker/Console/Kernel.php @@ -88,6 +88,9 @@ protected function schedule(Schedule $schedule) $schedule->command('metrics:clear')->cron("*/{$clearInterval} * * * *"); break; } + + // 5 minutes is recommended in https://laravel.com/docs/12.x/horizon#metrics + $schedule->command('horizon:snapshot')->everyFiveMinutes(); } /** diff --git a/ProcessMaker/Contracts/PermissionCacheInterface.php b/ProcessMaker/Contracts/PermissionCacheInterface.php new file mode 100644 index 0000000000..e76dc9286e --- /dev/null +++ b/ProcessMaker/Contracts/PermissionCacheInterface.php @@ -0,0 +1,41 @@ +group = $group; + $this->parentGroup = $parentGroup; + $this->action = $action; + $this->groupMember = $groupMember; + } + + /** + * Get the group that was affected + */ + public function getGroup(): ?Group + { + return $this->group; + } + + /** + * Get the parent group (if any) + */ + public function getParentGroup(): ?Group + { + return $this->parentGroup; + } + + /** + * Get the action performed + */ + public function getAction(): string + { + return $this->action; + } + + /** + * Get the group member record + */ + public function getGroupMember(): ?GroupMember + { + return $this->groupMember; + } + + /** + * Check if this is a removal action + */ + public function isRemoval(): bool + { + return $this->action === 'removed'; + } + + /** + * Check if this is an addition action + */ + public function isAddition(): bool + { + return $this->action === 'added'; + } + + /** + * Check if this is an update action + */ + public function isUpdate(): bool + { + return $this->action === 'updated'; + } +} diff --git a/ProcessMaker/Events/PermissionUpdated.php b/ProcessMaker/Events/PermissionUpdated.php index 08d6f551ef..ea4f077544 100644 --- a/ProcessMaker/Events/PermissionUpdated.php +++ b/ProcessMaker/Events/PermissionUpdated.php @@ -148,4 +148,24 @@ public function getEventName(): string { return 'PermissionUpdated'; } + + /** + * Get the user ID + * + * @return string|null + */ + public function getUserId(): ?string + { + return $this->userId; + } + + /** + * Get the group ID + * + * @return string|null + */ + public function getGroupId(): ?string + { + return $this->groupId; + } } diff --git a/ProcessMaker/Exception/Handler.php b/ProcessMaker/Exception/Handler.php index 86dd173454..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"; @@ -146,18 +151,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; - } } diff --git a/ProcessMaker/Exception/MultitenancyAccessedLandlord.php b/ProcessMaker/Exception/MultitenancyAccessedLandlord.php index d2bb0654ed..cfeb78fd3b 100644 --- a/ProcessMaker/Exception/MultitenancyAccessedLandlord.php +++ b/ProcessMaker/Exception/MultitenancyAccessedLandlord.php @@ -5,11 +5,21 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; +use ProcessMaker\Facades\Metrics; class MultitenancyAccessedLandlord extends Exception { public function render(Request $request): Response { + // If we're trying to access the /metrics route, collect landlord metrics and render them + if ($request->path() === 'metrics') { + Metrics::collectQueueMetrics(); + + return response(Metrics::renderMetrics(), 200, [ + 'Content-Type' => 'text/plain; version=0.0.4', + ]); + } + return response()->view('multitenancy.landlord-landing-page'); } diff --git a/ProcessMaker/Http/Controllers/Admin/QueuesController.php b/ProcessMaker/Http/Controllers/Admin/QueuesController.php index b156f05b7c..0dc78b5369 100644 --- a/ProcessMaker/Http/Controllers/Admin/QueuesController.php +++ b/ProcessMaker/Http/Controllers/Admin/QueuesController.php @@ -5,18 +5,28 @@ use Illuminate\Auth\Access\AuthorizationException; use ProcessMaker\Events\QueueManagementAccessed; use ProcessMaker\Http\Controllers\Controller; +use ProcessMaker\Providers\TenantQueueServiceProvider; class QueuesController extends Controller { public function index() { - if (auth()->user()->is_administrator) { - // Register the Event - QueueManagementAccessed::dispatch(); + if (!auth()->user()->is_administrator) { + throw new AuthorizationException(); + } - return view('admin.queues.index'); + if (config('app.multitenancy')) { + if (!TenantQueueServiceProvider::allowAllTenats()) { + // Its multitenancy and they don't have access to all tenants so + // redirect to the tenant-filtered queue management page. + // Otherwise, show the horizon queue manager. + return redirect()->route('tenant-queue.index'); + } } - throw new AuthorizationException(); + // Register the Event + QueueManagementAccessed::dispatch(); + + return view('admin.queues.index'); } } diff --git a/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php b/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php index 5635224eda..2d8198434c 100644 --- a/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php +++ b/ProcessMaker/Http/Controllers/Admin/TenantQueueController.php @@ -14,29 +14,12 @@ class TenantQueueController extends Controller { - /** - * Constructor to check if tenant tracking is enabled. - */ - public function __construct() - { - // Check if tenant job tracking is enabled - $enabled = config('queue.tenant_tracking_enabled', false); - - if (!$enabled) { - if (!app()->runningInConsole()) { - abort(404, 'Tenant queue tracking is disabled'); - } - } - } - /** * Show the tenant jobs dashboard. */ public function index() { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); return view('admin.tenant-queues.index'); } @@ -46,12 +29,16 @@ public function index() */ public function getTenants(): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantsWithJobs = TenantQueueServiceProvider::getTenantsWithJobs(); + if (!TenantQueueServiceProvider::allowAllTenats()) { + $tenantsWithJobs = array_filter($tenantsWithJobs, function ($tenantData) { + return (int) $tenantData['id'] === app('currentTenant')?->id; + }); + } + // Enrich with tenant information $tenants = []; foreach ($tenantsWithJobs as $tenantData) { @@ -74,9 +61,7 @@ public function getTenants(): JsonResponse */ public function getTenantJobs(Request $request, string $tenantId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $status = $request->get('status'); $limit = min((int) $request->get('limit', 50), 100); // Max 100 jobs @@ -112,12 +97,16 @@ public function getTenantStats(string $tenantId): JsonResponse */ public function getOverallStats(): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantsWithJobs = TenantQueueServiceProvider::getTenantsWithJobs(); + if (!TenantQueueServiceProvider::allowAllTenats()) { + $tenantsWithJobs = array_filter($tenantsWithJobs, function ($tenantData) { + return (int) $tenantData['id'] === app('currentTenant')?->id; + }); + } + $overallStats = [ 'total_tenants' => count($tenantsWithJobs), 'total_jobs' => 0, @@ -144,9 +133,7 @@ public function getOverallStats(): JsonResponse */ public function getJobDetails(string $tenantId, string $jobId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); $tenantKey = "tenant_jobs:{$tenantId}:{$jobId}"; $jobData = Redis::hgetall($tenantKey); @@ -180,9 +167,7 @@ public function getJobDetails(string $tenantId, string $jobId): JsonResponse */ public function clearTenantJobs(string $tenantId): JsonResponse { - if (!Auth::user()->is_administrator) { - throw new AuthorizationException(); - } + $this->checkPermissions(); try { $pattern = "tenant_jobs:{$tenantId}:*"; @@ -209,4 +194,25 @@ public function clearTenantJobs(string $tenantId): JsonResponse return response()->json(['error' => 'Failed to clear tenant job data'], 500); } } + + private function checkPermissions(): void + { + // Check if tenant job tracking is enabled + $enabled = TenantQueueServiceProvider::enabled(); + + if (!$enabled) { + throw new AuthorizationException('Tenant queue tracking is disabled'); + } + + if (!Auth::user()->is_administrator) { + throw new AuthorizationException(); + } + + // If the route binding has a tenant id, check if the user is allowed to access the tenant queue + if ($id = (int) request()->route('tenantId')) { + if (!TenantQueueServiceProvider::allowAllTenats() && $id !== app('currentTenant')?->id) { + throw new AuthorizationException(); + } + } + } } diff --git a/ProcessMaker/Http/Controllers/Api/DevLinkController.php b/ProcessMaker/Http/Controllers/Api/DevLinkController.php index 399323ea0c..8994184d1a 100644 --- a/ProcessMaker/Http/Controllers/Api/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Api/DevLinkController.php @@ -355,12 +355,14 @@ public function removeSharedAsset($id) public function installRemoteAsset(Request $request, DevLink $devLink) { + $updateType = $request->input('updateType', DevLinkInstall::MODE_UPDATE); + DevLinkInstall::dispatch( $request->user()->id, $devLink->id, $request->input('class'), $request->input('id'), - DevLinkInstall::MODE_UPDATE, + $updateType, DevLinkInstall::TYPE_IMPORT_ASSET ); diff --git a/ProcessMaker/Http/Controllers/Api/FileController.php b/ProcessMaker/Http/Controllers/Api/FileController.php index 8cd1de0171..2aa2e217b6 100644 --- a/ProcessMaker/Http/Controllers/Api/FileController.php +++ b/ProcessMaker/Http/Controllers/Api/FileController.php @@ -13,10 +13,13 @@ use ProcessMaker\Models\MediaLog; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\TaskDraft; +use ProcessMaker\Traits\ValidatesFileTrait; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class FileController extends Controller { + use ValidatesFileTrait; + /** * A whitelist of attributes that should not be * sanitized by our SanitizeInput middleware. @@ -188,7 +191,21 @@ public function store(Request $request) } $mediaCollection = $request->input('collection', 'local'); + + // Validate the file before processing + $uploadedFile = $request->file('file'); + if (!$uploadedFile) { + return abort(response(['message' => 'No file provided'], 422)); + } + + $errors = []; + $this->validateFile($uploadedFile, $errors); + if (count($errors) > 0) { + return abort(response($errors, 422)); + } + $file = $model->addMediaFromRequest('file'); + $user = pmUser(); $originalCreatedBy = $user ? $user->id : null; $data_name = $request->input('data_name', ''); diff --git a/ProcessMaker/Http/Controllers/Api/PermissionController.php b/ProcessMaker/Http/Controllers/Api/PermissionController.php index be05665a3c..5fed46c80b 100644 --- a/ProcessMaker/Http/Controllers/Api/PermissionController.php +++ b/ProcessMaker/Http/Controllers/Api/PermissionController.php @@ -122,16 +122,9 @@ public function update(Request $request) //Sync the entity's permissions with the database $entity->permissions()->sync($permissions->pluck('id')->toArray()); - // Clear user permissions cache and rebuild - $this->clearAndRebuildCache($entity); + // The PermissionUpdated event will automatically trigger cache invalidation + // via the InvalidatePermissionCacheOnUpdate listener return response([], 204); } - - private function clearAndRebuildCache($user) - { - // Rebuild and update the permissions cache - $permissions = $user->permissions()->pluck('name')->toArray(); - Cache::put("user_{$user->id}_permissions", $permissions, 86400); - } } diff --git a/ProcessMaker/Http/Controllers/Api/ProcessController.php b/ProcessMaker/Http/Controllers/Api/ProcessController.php index b4fb419573..e8b29b9e25 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessController.php @@ -429,7 +429,7 @@ public function store(Request $request) //set manager id if ($request->has('manager_id')) { - $process->manager_id = $request->input('manager_id', null); + $process->manager_id = $this->validateMaxManagers($request); } if (isset($data['bpmn'])) { @@ -542,7 +542,7 @@ public function update(Request $request, Process $process) $process->fill($request->except('notifications', 'task_notifications', 'notification_settings', 'cancel_request', 'cancel_request_id', 'start_request_id', 'edit_data', 'edit_data_id', 'projects')); if ($request->has('manager_id')) { - $process->manager_id = $request->input('manager_id', null); + $process->manager_id = $this->validateMaxManagers($request); } if ($request->has('user_id')) { @@ -621,6 +621,55 @@ public function update(Request $request, Process $process) return new Resource($process->refresh()); } + private function validateMaxManagers(Request $request) + { + $managerIds = $request->input('manager_id', []); + + // Handle different input types + if (is_string($managerIds)) { + // If it's a string, try to decode it as JSON + if (empty($managerIds)) { + $managerIds = []; + } else { + $decoded = json_decode($managerIds, true); + + // Handle JSON decode failure + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Illuminate\Validation\ValidationException( + validator([], []), + ['manager_id' => [__('Invalid JSON format for manager_id')]] + ); + } + + $managerIds = $decoded; + } + } + + // Ensure we have an array + if (!is_array($managerIds)) { + // If it's a single value (not array), convert to array + $managerIds = [$managerIds]; + } + + // Filter out null, empty values and validate each manager ID + $managerIds = array_filter($managerIds, function ($id) { + return $id !== null && $id !== '' && is_numeric($id) && $id > 0; + }); + + // Re-index the array to remove gaps from filtered values + $managerIds = array_values($managerIds); + + // Validate maximum number of managers + if (count($managerIds) > 10) { + throw new \Illuminate\Validation\ValidationException( + validator([], []), + ['manager_id' => [__('Maximum number of managers is :max', ['max' => 10])]] + ); + } + + return $managerIds; + } + /** * Validate the structure of stages. * @@ -1714,7 +1763,7 @@ protected function checkUserCanStartProcess($event, $currentUser, $process, $req } break; case 'process_manager': - $response = $currentUser === $process->manager_id; + $response = in_array($currentUser, $process->manager_id ?? []); break; } } diff --git a/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php b/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php index 5e38a77d41..730da17d32 100644 --- a/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php +++ b/ProcessMaker/Http/Controllers/Api/ProcessRequestFileController.php @@ -3,17 +3,13 @@ namespace ProcessMaker\Http\Controllers\Api; use Exception; -use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Storage; use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException; use Pion\Laravel\ChunkUpload\Handler\AbstractHandler; -use Pion\Laravel\ChunkUpload\Handler\HandlerFactory; use Pion\Laravel\ChunkUpload\Receiver\FileReceiver; use ProcessMaker\Events\FilesAccessed; use ProcessMaker\Events\FilesCreated; @@ -21,14 +17,16 @@ use ProcessMaker\Events\FilesDownloaded; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Http\Resources\ApiCollection; -use ProcessMaker\Http\Resources\ApiResource; use ProcessMaker\Models\Media; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\TaskDraft; +use ProcessMaker\Traits\ValidatesFileTrait; use Spatie\MediaLibrary\MediaCollections\Exceptions\FileIsTooBig; class ProcessRequestFileController extends Controller { + use ValidatesFileTrait; + /** * A whitelist of attributes that should not be * sanitized by our SanitizeInput middleware. @@ -439,124 +437,4 @@ public function destroy(Request $laravel_request, ProcessRequest $request, $file return response([], 204); } - - /** - * Validate uploaded file for security and type restrictions - * - * @param UploadedFile $file - * @param array $errors - * @return array - */ - private function validateFile(UploadedFile $file, &$errors) - { - // Explicitly reject archive files for security - $this->rejectArchiveFiles($file, $errors); - - // Validate file extension if enabled - if (config('files.enable_extension_validation', true)) { - $this->validateFileExtension($file, $errors); - } - - // Validate MIME type vs extension if enabled - if (config('files.enable_mime_validation', true)) { - $this->validateExtensionMimeTypeMatch($file, $errors); - } - - // Validate specific file types (e.g., PDF for JavaScript content) - if (strtolower($file->getClientOriginalExtension()) === 'pdf') { - $this->validatePDFFile($file, $errors); - } - - return $errors; - } - - /** - * Explicitly reject archive files for security reasons - * - * @param UploadedFile $file - * @param array $errors - * @return void - */ - private function rejectArchiveFiles(UploadedFile $file, &$errors) - { - $dangerousExtensions = config('files.dangerous_extensions'); - - $fileExtension = strtolower($file->getClientOriginalExtension()); - - if (in_array($fileExtension, $dangerousExtensions)) { - $errors['message'] = __('Uploaded file type is not allowed'); - - return; - } - - // Also check MIME types for archive files - $dangerousMimeTypes = config('files.dangerous_mime_types'); - - $fileMimeType = $file->getMimeType(); - - if (in_array($fileMimeType, $dangerousMimeTypes)) { - $errors['message'] = __('Uploaded mime file type is not allowed'); - } - } - - /** - * Validate that file extension matches the MIME type - * - * @param UploadedFile $file - * @param array $errors - * @return void - */ - private function validateExtensionMimeTypeMatch(UploadedFile $file, &$errors) - { - $fileExtension = strtolower($file->getClientOriginalExtension()); - $fileMimeType = $file->getMimeType(); - - // Get extension to MIME type mapping from configuration - $extensionMimeMap = config('files.extension_mime_map'); - - // Check if extension exists in our map - if (!isset($extensionMimeMap[$fileExtension])) { - $errors['message'] = __('File extension not allowed'); - - return; - } - - // Check if MIME type matches any of the expected types for this extension - if (!in_array($fileMimeType, $extensionMimeMap[$fileExtension])) { - $errors['message'] = __('The file extension does not match the actual file content'); - } - } - - /** - * Validate file extension against allowed extensions - * - * @param UploadedFile $file - * @param array $errors - * @return void - */ - private function validateFileExtension(UploadedFile $file, &$errors) - { - $allowedExtensions = config('files.allowed_extensions'); - $fileExtension = strtolower($file->getClientOriginalExtension()); - - if (!in_array($fileExtension, $allowedExtensions)) { - $errors['message'] = __('File extension not allowed'); - } - } - - private function validatePDFFile(UploadedFile $file, &$errors) - { - $text = $file->get(); - - $jsKeywords = ['/JavaScript', '<< /S /JavaScript']; - - foreach ($jsKeywords as $keyword) { - if (strpos($text, $keyword) !== false) { - $errors[] = __('Dangerous PDF file content'); - break; - } - } - - return $errors; - } } diff --git a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php index ae85d35821..2564272691 100644 --- a/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php +++ b/ProcessMaker/Http/Controllers/Api/ScriptExecutorController.php @@ -59,7 +59,15 @@ public function index(Request $request) { $this->checkAuth($request); - return new ApiCollection(ScriptExecutor::nonSystem()->get()); + $query = ScriptExecutor::nonSystem(); + + if ($request->has('order_by')) { + $order_by = $request->input('order_by'); + $order_direction = $request->input('order_direction', 'ASC'); + $query->orderBy($order_by, $order_direction); + } + + return new ApiCollection($query->get()); } /** diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index cd4cb5fd1c..ee80c79975 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -146,15 +146,15 @@ public function index(Request $request, $getTotal = false, User $user = null) $this->applyAdvancedFilter($query, $request); - $this->applyForCurrentUser($query, $user); - - // Apply filter overdue - $query->overdue($request->input('overdue')); - if ($request->input('processesIManage') === 'true') { $this->applyProcessManager($query, $user); + } else { + $this->applyForCurrentUser($query, $user); } + // Apply filter overdue + $query->overdue($request->input('overdue')); + // If only the total is being requested (by a Saved Search), send it now if ($getTotal === true) { return $query->count(); @@ -168,6 +168,11 @@ public function index(Request $request, $getTotal = false, User $user = null) $response = $this->applyUserFilter($response, $request, $user); + if ($response->total() > 0 && $request->input('processesIManage') === 'true') { + // enable user manager in cache + $this->enableUserManager($user); + } + $inOverdueQuery = ProcessRequestToken::query() ->whereIn('id', $response->pluck('id')) ->where('due_at', '<', Carbon::now()); diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index 34c3d6c758..070483b348 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -209,10 +209,14 @@ public function getUsersTaskCount(Request $request) $include_ids = explode(',', $include_ids_string); } elseif ($request->has('assignable_for_task_id')) { $task = ProcessRequestToken::findOrFail($request->input('assignable_for_task_id')); - if ($task->getAssignmentRule() === 'user_group') { + $assignmentRule = $task->getAssignmentRule(); + if ($assignmentRule === 'user_group') { // Limit the list of users to those that can be assigned to the task $include_ids = $task->process->getAssignableUsers($task->element_id); } + if ($assignmentRule === 'rule_expression' && $request->has('form_data')) { + $include_ids = $task->getAssigneesFromExpression($request->input('form_data')); + } } if (!empty($include_ids)) { diff --git a/ProcessMaker/Http/Controllers/CasesController.php b/ProcessMaker/Http/Controllers/CasesController.php index 3649a001cf..c179bad86f 100644 --- a/ProcessMaker/Http/Controllers/CasesController.php +++ b/ProcessMaker/Http/Controllers/CasesController.php @@ -95,7 +95,7 @@ public function show($case_number) // The user can see the comments $canViewComments = (Auth::user()->hasPermissionsFor('comments')->count() > 0) || class_exists(PackageServiceProvider::class); // The user is Manager from the main request - $isProcessManager = $request->process?->manager_id === Auth::user()->id; + $isProcessManager = in_array(Auth::user()->id, $request->process?->manager_id ?? []); // Check if the user has permission print for request $canPrintScreens = $canOpenCase = $this->canUserCanOpenCase($allRequests); if (!$canOpenCase && !$isProcessManager) { diff --git a/ProcessMaker/Http/Kernel.php b/ProcessMaker/Http/Kernel.php index f8a5888430..e3f9e8a0f5 100644 --- a/ProcessMaker/Http/Kernel.php +++ b/ProcessMaker/Http/Kernel.php @@ -27,6 +27,7 @@ class Kernel extends HttpKernel ServerTimingMiddleware::class, Middleware\FileSizeCheck::class, Middleware\AddTenantHeaders::class, + Middleware\HideServerHeaders::class, ]; /** @@ -91,6 +92,7 @@ class Kernel extends HttpKernel 'session_kill' => Middleware\SessionControlKill::class, 'no-cache' => Middleware\NoCache::class, 'admin' => Middleware\IsAdmin::class, + 'manager' => Middleware\IsManager::class, 'etag' => Middleware\Etag\HandleEtag::class, 'file_size_check' => Middleware\FileSizeCheck::class, ]; diff --git a/ProcessMaker/Http/Middleware/HideServerHeaders.php b/ProcessMaker/Http/Middleware/HideServerHeaders.php new file mode 100644 index 0000000000..31a9258648 --- /dev/null +++ b/ProcessMaker/Http/Middleware/HideServerHeaders.php @@ -0,0 +1,89 @@ +shouldHideHeaders()) { + // Remove all server-revealing headers + foreach ($this->headersToRemove as $header) { + $response->headers->remove($header); + } + + // Set a generic server header to avoid revealing the absence + $response->headers->set('Server', 'ProcessMaker Server'); + } + + return $response; + } + + /** + * Determine if headers should be hidden based on environment + * + * @return bool + */ + private function shouldHideHeaders(): bool + { + // Hide headers in production or when explicitly configured + return app()->environment('production') || + config('app.hide_server_headers', false); + } +} diff --git a/ProcessMaker/Http/Middleware/IsManager.php b/ProcessMaker/Http/Middleware/IsManager.php new file mode 100644 index 0000000000..638072f2be --- /dev/null +++ b/ProcessMaker/Http/Middleware/IsManager.php @@ -0,0 +1,195 @@ +user(); + + if (!$user) { + return abort(401, 'Unauthenticated'); + } + + // if user is administrator, allow access + if ($user->is_administrator) { + return $next($request); + } + + if (!Cache::get("user_{$user->id}_manager")) { + // if user is not manager, continue + return $next($request); + } + + // get the required permissions for this specific URL + $requiredPermissions = $this->getRequiredPermissionsForRequest($request); + + if (empty($requiredPermissions)) { + // if no required permissions, continue + return $next($request); + } + + // simulate that the user has all the necessary permissions for this request + $this->simulateRequiredPermissionsForRequest($user, $requiredPermissions); + + try { + // process the request - the internal endpoints will handle the permission validation + $response = $next($request); + + // clean up the simulated permissions after processing the request + $this->cleanupSimulatedPermission($user); + + return $response; + } catch (\Exception $e) { + // make sure to clean up the simulated permissions even if there is an exception + $this->cleanupSimulatedPermission($user); + throw $e; + } + } + + /** + * Simula que el usuario tiene los permisos requeridos solo para esta solicitud + */ + private function simulateRequiredPermissionsForRequest($user, array $requiredPermissions) + { + try { + // get the current permissions of the user + $currentPermissions = $user->loadPermissions(); + + // filter only the permissions that the user does not have + $permissionsToAdd = array_diff($requiredPermissions, $currentPermissions); + + if (empty($permissionsToAdd)) { + return; + } + + // simulate the permissions by adding them temporarily to the cache of permissions + $cacheKey = "user_{$user->id}_permissions"; + $simulatedPermissions = array_merge($currentPermissions, $permissionsToAdd); + + // save in cache temporarily (only for this request) + // use a very short time to expire quickly if not cleaned manually + Cache::put($cacheKey, $simulatedPermissions, 5); // 5 segundos como fallback + } catch (\Exception $e) { + Log::error('IsManager middleware - Error simulating permissions: ' . $e->getMessage()); + } + } + + /** + * clean up the simulated permissions from the cache after processing the request + */ + private function cleanupSimulatedPermission($user) + { + try { + $cacheKey = "user_{$user->id}_permissions"; + + // delete the cache to force the reload of real permissions + Cache::forget($cacheKey); + } catch (\Exception $e) { + Log::error('IsManager middleware - Error cleaning up simulated permissions: ' . $e->getMessage()); + } + } + + /** + * get the required permissions for the current URL + */ + private function getRequiredPermissionsForRequest(Request $request): array + { + $permissions = []; + + try { + $url = $request->fullUrl(); + $path = $request->path(); + $method = $request->method(); + + // first, get permissions from middlewares of the route + $middlewarePermissions = $this->getPermissionsFromMiddlewares($request); + $permissions = array_merge($permissions, $middlewarePermissions); + + // then, get permissions based on URL patterns + $urlPermissions = $this->getPermissionsFromUrlPatterns($url, $path, $method); + $permissions = array_merge($permissions, $urlPermissions); + } catch (\Exception $e) { + Log::error('IsManager middleware - Error getting required permissions: ' . $e->getMessage()); + } + + return array_unique($permissions); + } + + /** + * get permissions from the middlewares of the route + */ + private function getPermissionsFromMiddlewares(Request $request): array + { + $permissions = []; + + try { + // get all the middlewares of the route + $middlewares = $request->route()->middleware(); + + // filter only the middlewares that contain 'can:' + $permissionMiddlewares = array_filter($middlewares, function ($middleware) { + return str_contains($middleware, 'can:'); + }); + + // extract the permissions from each middleware + foreach ($permissionMiddlewares as $middleware) { + // format: "can:permission" or "can:permission,model" + if (preg_match('/can:([^,]+)/', $middleware, $matches)) { + $permissions[] = $matches[1]; + } + } + } catch (\Exception $e) { + Log::error('IsManager middleware - Error getting permissions from middlewares: ' . $e->getMessage()); + } + + return $permissions; + } + + /** + * get permissions based on URL patterns + */ + private function getPermissionsFromUrlPatterns(string $url, string $path, string $method): array + { + $permissions = []; + + // for now we only support GET methods + if ($method !== 'GET') { + return $permissions; + } + + try { + // URL patterns and their corresponding permissions + $urlPatterns = [ + // patterns for users + '/api\/.*\/users(\?.*)?$/' => 'view-users', + + // patterns for saved searches + '/api\/.*\/saved-searches\/columns(\?.*)?$/' => 'view-saved-searches-columns', + ]; + + // check each pattern + foreach ($urlPatterns as $pattern => $permission) { + if (preg_match($pattern, $url)) { + $permissions[] = $permission; + } + } + } catch (\Exception $e) { + Log::error('IsManager middleware - Error getting permissions from URL patterns: ' . $e->getMessage()); + } + + return $permissions; + } +} diff --git a/ProcessMaker/Http/Resources/V1_1/TaskResource.php b/ProcessMaker/Http/Resources/V1_1/TaskResource.php index cec32ced7e..26755cd5f5 100644 --- a/ProcessMaker/Http/Resources/V1_1/TaskResource.php +++ b/ProcessMaker/Http/Resources/V1_1/TaskResource.php @@ -107,6 +107,7 @@ class TaskResource extends ApiResource 'case_number', 'callable_id', 'process_version_id', + 'name', ], 'draft' => ['id', 'task_id', 'data'], 'screen' => ['id', 'config'], diff --git a/ProcessMaker/ImportExport/Exporters/ExporterBase.php b/ProcessMaker/ImportExport/Exporters/ExporterBase.php index 5e477b1928..f4ac9c4d90 100644 --- a/ProcessMaker/ImportExport/Exporters/ExporterBase.php +++ b/ProcessMaker/ImportExport/Exporters/ExporterBase.php @@ -308,8 +308,8 @@ public function toArray() 'dependents' => array_map(fn ($d) => $d->toArray(), $this->dependents), 'name' => $this->getName($this->model), 'description' => $this->getDescription(), - 'process_manager' => $this->getProcessManager()['managerName'], - 'process_manager_id' => $this->getProcessManager()['managerId'], + 'process_manager' => $this->getProcessManager(), + 'process_manager_id' => $this->getProcessManagerIds(), 'attributes' => $this->getExportAttributes(), 'extraAttributes' => $this->getExtraAttributes($this->model), 'references' => $this->references, @@ -383,10 +383,36 @@ public function getExtraAttributes($model): array public function getProcessManager(): array { - return [ - 'managerId' => $this->model->manager?->id ? $this->model->manager->id : null, - 'managerName' => $this->model->manager?->fullname ? $this->model->manager->fullname : '', - ]; + // Check if the model has the getManagers method + if (!method_exists($this->model, 'getManagers')) { + return []; + } + + $managers = $this->model->getManagers() ?? []; + + $managerNames = []; + foreach ($managers as $manager) { + $managerNames[] = $manager->fullname; + } + + return $managerNames; + } + + public function getProcessManagerIds(): array + { + // Check if the model has the getManagers method + if (!method_exists($this->model, 'getManagers')) { + return []; + } + + $managers = $this->model->getManagers() ?? []; + + $managerIds = []; + foreach ($managers as $manager) { + $managerIds[] = $manager->id; + } + + return $managerIds; } public function getLastModifiedBy() : array diff --git a/ProcessMaker/ImportExport/Exporters/ProcessExporter.php b/ProcessMaker/ImportExport/Exporters/ProcessExporter.php index d5a88927df..1fdd81fc6a 100644 --- a/ProcessMaker/ImportExport/Exporters/ProcessExporter.php +++ b/ProcessMaker/ImportExport/Exporters/ProcessExporter.php @@ -38,8 +38,12 @@ public function export() : void $this->addDependent('user', $process->user, UserExporter::class); } - if ($process->manager) { - $this->addDependent('manager', $process->manager, UserExporter::class, null, ['properties']); + $managers = $process->getManagers(); + + if ($managers) { + foreach ($managers as $manager) { + $this->addDependent('manager', $manager, UserExporter::class, null, ['properties']); + } } $this->exportScreens(); @@ -99,9 +103,11 @@ public function import($existingAssetInDatabase = null, $importingFromTemplate = $process->user_id = User::where('is_administrator', true)->firstOrFail()->id; } + $managers = []; foreach ($this->getDependents('manager') as $dependent) { - $process->manager_id = $dependent->model->id; + $managers[] = $dependent->model->id; } + $process->manager_id = $managers; // Avoid associating the category from the manifest with processes imported from templates. // Use the user-selected category instead. diff --git a/ProcessMaker/ImportExport/Exporters/ScreenExporter.php b/ProcessMaker/ImportExport/Exporters/ScreenExporter.php index 03370ed26d..119803feae 100644 --- a/ProcessMaker/ImportExport/Exporters/ScreenExporter.php +++ b/ProcessMaker/ImportExport/Exporters/ScreenExporter.php @@ -97,11 +97,12 @@ private function getNestedScreens() : array $screenFinder = new ScreensInScreen(); foreach ($screenFinder->referencesToExport($this->model, [], null, false) as $screen) { try { - $screen = Screen::find($screen[1]); + $screenId = $screen[1]; + $screen = Screen::find($screenId); if ($screen) { $screens[] = $screen; } else { - \Log::debug("NestedScreen screenId: $screen[1] not exists"); + \Log::debug("NestedScreen screenId: $screenId not exists"); } } catch (ModelNotFoundException $error) { \Log::error($error->getMessage()); diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index 186742ecce..f78ddaf647 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -6,6 +6,7 @@ use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; @@ -173,7 +174,9 @@ public function withUpdatedContext(callable $callable) private function lockInstance($instanceId) { try { - $instance = ProcessRequest::findOrFail($instanceId); + // First attempt to find the instance with retry logic for race conditions + $instance = $this->findInstanceWithRetry($instanceId); + if (config('queue.default') === 'sync') { return $instance; } @@ -211,6 +214,43 @@ private function lockInstance($instanceId) throw new Exception('Unable to lock instance #' . $this->instanceId . ": Timeout {$timeout}[ms]"); } + /** + * Find ProcessRequest with retry logic to handle race conditions + * + * @param int $instanceId + * @return ProcessRequest + * @throws Exception + */ + private function findInstanceWithRetry($instanceId) + { + $maxRetries = config('app.bpmn_actions_find_retries', 5); + $retryDelay = config('app.bpmn_actions_find_retry_delay', 50); // milliseconds + + // Always attempt at least once, regardless of maxRetries value + $totalAttempts = max(1, $maxRetries); + + for ($attempt = 0; $attempt < $totalAttempts; $attempt++) { + try { + $instance = ProcessRequest::findOrFail($instanceId); + + return $instance; + } catch (ModelNotFoundException $e) { + if ($attempt === $totalAttempts - 1) { + // Last attempt failed, re-throw the exception + throw $e; + } + + // Wait before retrying (exponential backoff) + $delay = $retryDelay * pow(2, $attempt); + $this->mSleep($delay); + + Log::warning("ProcessRequest #{$instanceId} not found, retrying in {$delay}ms (attempt " . ($attempt + 1) . "/{$totalAttempts})"); + } + } + + throw new ModelNotFoundException("ProcessRequest #{$instanceId} not found after {$totalAttempts} attempts"); + } + /** * Request a lock for the instance * @param array $ids diff --git a/ProcessMaker/Jobs/DevLinkInstall.php b/ProcessMaker/Jobs/DevLinkInstall.php index 25c347fbf3..c30519e201 100644 --- a/ProcessMaker/Jobs/DevLinkInstall.php +++ b/ProcessMaker/Jobs/DevLinkInstall.php @@ -67,7 +67,7 @@ public function handle(): void $bundle->reinstall($this->importMode, $logger); break; case self::TYPE_IMPORT_ASSET: - $devLink->installRemoteAsset($this->class, $this->id, $logger); + $devLink->installRemoteAsset($this->class, $this->id, $this->importMode, $logger); break; default: break; diff --git a/ProcessMaker/Jobs/ErrorHandling.php b/ProcessMaker/Jobs/ErrorHandling.php index ea0049a742..e6ec5df652 100644 --- a/ProcessMaker/Jobs/ErrorHandling.php +++ b/ProcessMaker/Jobs/ErrorHandling.php @@ -93,10 +93,11 @@ private function requeue($job) public function sendExecutionErrorNotification(string $message) { if ($this->processRequestToken) { - $user = $this->processRequestToken->processRequest->processVersion->manager; - if ($user !== null) { - Log::info('Send Execution Error Notification: ' . $message); - Notification::send($user, new ErrorExecutionNotification($this->processRequestToken, $message, $this->bpmnErrorHandling)); + // review no multiple managers + $mangers = $this->processRequestToken->processRequest->processVersion->getManagers(); + foreach ($mangers as $manager) { + Log::info('Send Execution Error Notification: ' . $message . ' to manager: ' . $manager->username); + Notification::send($manager, new ErrorExecutionNotification($this->processRequestToken, $message, $this->bpmnErrorHandling)); } } } @@ -213,7 +214,7 @@ public static function convertResponseToException($result) if (str_starts_with($result['message'], 'Command exceeded timeout of')) { throw new ScriptTimeoutException($result['message']); } - throw new ScriptException($result['message']); + throw new ScriptException(json_encode($result, JSON_PRETTY_PRINT)); } } } diff --git a/ProcessMaker/Jobs/RefreshArtisanCaches.php b/ProcessMaker/Jobs/RefreshArtisanCaches.php index 0ca44566dc..074c569db1 100644 --- a/ProcessMaker/Jobs/RefreshArtisanCaches.php +++ b/ProcessMaker/Jobs/RefreshArtisanCaches.php @@ -3,6 +3,7 @@ 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; @@ -13,28 +14,6 @@ class RefreshArtisanCaches implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; - public $tries = 1; - - /** - * Create a new job instance. - * - * @return void - */ - public function __construct() - { - // - } - - /** - * Debounce when multiple Settings are saved at the same time - * - * @return array - */ - public function middleware(): array - { - return [(new WithoutOverlapping('refresh_artisan_caches'))->dontRelease()]; - } - /** * Execute the job. * @@ -42,6 +21,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, @@ -49,7 +35,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); } } diff --git a/ProcessMaker/Jobs/TerminateRequestEndEvent.php b/ProcessMaker/Jobs/TerminateRequestEndEvent.php index c8bc13ee99..3abd856e81 100644 --- a/ProcessMaker/Jobs/TerminateRequestEndEvent.php +++ b/ProcessMaker/Jobs/TerminateRequestEndEvent.php @@ -40,5 +40,7 @@ public function action(ProcessRequest $instance) $instance->status = 'COMPLETED'; $instance->save(); + + CaseUpdateStatus::dispatchSync($instance); } } diff --git a/ProcessMaker/LicensedPackageManifest.php b/ProcessMaker/LicensedPackageManifest.php index abed47193e..cac7ca6243 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,20 +22,6 @@ 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() - { - $manifest = parent::getManifest(); - $multitenancyKey = 'spatie/laravel-multitenancy'; - - // Make sure the MultitenancyServiceProvider is at the beginning of the manifest - return [$multitenancyKey => $manifest[$multitenancyKey]] + $manifest; - } - protected function packagesToIgnore() { $packagesToIgnore = $this->loadPackagesToIgnore()->all(); @@ -66,7 +54,7 @@ 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); } @@ -87,7 +75,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/ProcessMaker/Listeners/CommentsSubscriber.php b/ProcessMaker/Listeners/CommentsSubscriber.php index 7e56b72727..f8ab44b4cb 100644 --- a/ProcessMaker/Listeners/CommentsSubscriber.php +++ b/ProcessMaker/Listeners/CommentsSubscriber.php @@ -2,6 +2,7 @@ namespace ProcessMaker\Listeners; +use Illuminate\Support\Facades\Auth; use ProcessMaker\Models\Comment; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; @@ -26,11 +27,27 @@ public function onActivityCompleted(ActivityCompletedEvent $event) $user_id = $token->user ? $token->user_id : null; $user_name = $token->user ? $token->user->fullname : __('The System'); + // Check the authenticated user + $executer_user_id = null; + $executer_user = __('The System'); + $user = Auth::user(); + if ($user) { + $executer_user_id = $user->id; + $executer_user = $user->fullname; + } + if (!is_int($token->process_request_id)) { return; } - $message = ':user has completed the task :task_name'; + if (!is_null($executer_user_id) && $user_id !== $executer_user_id) { + $subject = 'Task Completed by Different User'; + $message = ':executer_user has completed the task :task_name (assigned to: :user)'; + } else { + $subject = 'Task Complete'; + $message = ':user has completed the task :task_name'; + } + if ($token->is_actionbyemail) { $message = $message . ' via email'; } @@ -42,8 +59,8 @@ public function onActivityCompleted(ActivityCompletedEvent $event) 'user_id' => $user_id, 'commentable_type' => ProcessRequest::class, 'commentable_id' => $token->process_request_id, - 'subject' => 'Task Complete', - 'body' => __($message, ['user' => $user_name, 'task_name' => $token->element_name]), + 'subject' => $subject, + 'body' => __($message, ['user' => $user_name, 'task_name' => $token->element_name, 'executer_user' => $executer_user]), 'case_number' => $caseNumber, ]); } diff --git a/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php b/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php new file mode 100644 index 0000000000..87a76e8954 --- /dev/null +++ b/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php @@ -0,0 +1,44 @@ +permissionService = $permissionService; + } + + /** + * Handle the event. + */ + public function handle(GroupMembershipChanged $event): void + { + try { + $group = $event->getGroup(); + $action = $event->getAction(); + + // All actions (added, removed, updated) require the same cache invalidation logic + // because they all affect the permission hierarchy for the group and its descendants + $this->permissionService->invalidateAll(); + + Log::info("Successfully invalidated permission cache for group hierarchy change: {$action} for group {$group->id}"); + } catch (\Exception $e) { + Log::error('Failed to invalidate permission cache on group hierarchy change', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'group_id' => $event->getGroup()->id ?? 'unknown', + 'action' => $event->getAction(), + ]); + throw $e; + } + } +} diff --git a/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php b/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php new file mode 100644 index 0000000000..cffd42af6a --- /dev/null +++ b/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php @@ -0,0 +1,43 @@ +permissionService = $permissionService; + } + + /** + * Handle the event. + */ + public function handle(PermissionUpdated $event): void + { + try { + // Invalidate cache for user if user permissions were updated + if ($event->getUserId()) { + $this->permissionService->invalidateUserCache((int) $event->getUserId()); + } + + // Invalidate cache for group if group permissions were updated + if ($event->getGroupId()) { + $this->permissionService->invalidateAll(); + } + } catch (\Exception $e) { + Log::error('Failed to invalidate permission cache', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'userId' => $event->getUserId(), + 'groupId' => $event->getGroupId(), + ]); + throw $e; // Re-throw to ensure error is properly handled + } + } +} diff --git a/ProcessMaker/Mail/TaskActionByEmail.php b/ProcessMaker/Mail/TaskActionByEmail.php index 6f453a8d5b..1a21df63f4 100644 --- a/ProcessMaker/Mail/TaskActionByEmail.php +++ b/ProcessMaker/Mail/TaskActionByEmail.php @@ -51,8 +51,9 @@ public function sendAbeEmail($config, $to, $data) if (!empty($emailScreenRef)) { // Retrieve and render custom screen if specified $customScreen = Screen::findOrFail($emailScreenRef); - $infoRenderer = $this->emailProvider->screenRenderer($customScreen->config, $data); + $infoRenderer = $this->emailProvider->screenRenderer($customScreen->config, $data, $customScreen); $emailConfig['body'] = $infoRenderer['body'] ?? __('No body configured'); + $emailConfig['styles'] = $infoRenderer['styles'] ?? ''; } else { // Default message if no custom screen is configured $emailConfig['body'] = __('No screen configured'); diff --git a/ProcessMaker/Managers/ExportManager.php b/ProcessMaker/Managers/ExportManager.php index ffb84347d2..859afac8f0 100644 --- a/ProcessMaker/Managers/ExportManager.php +++ b/ProcessMaker/Managers/ExportManager.php @@ -83,13 +83,11 @@ private function reviewDependenciesOf(ProcessMakerModel $owner, array $reference $newReferences = $this->uniqueDiff($newReferences, $references); $references = array_merge($references, $newReferences); // Find recursively dependencies - if ($recursive) { - foreach ($newReferences as $ref) { - list($class, $id) = $ref; - $nextOwner = $class::find($id); - if ($nextOwner) { - $references = $this->reviewDependenciesOf($nextOwner, $references, $reviewed, $recursive); - } + foreach ($newReferences as $ref) { + list($class, $id) = $ref; + $nextOwner = $class::find($id); + if ($nextOwner) { + $references = $this->reviewDependenciesOf($nextOwner, $references, $reviewed, $recursive); } } diff --git a/ProcessMaker/Managers/TaskSchedulerManager.php b/ProcessMaker/Managers/TaskSchedulerManager.php index d59dc4db80..f992010de2 100644 --- a/ProcessMaker/Managers/TaskSchedulerManager.php +++ b/ProcessMaker/Managers/TaskSchedulerManager.php @@ -195,7 +195,7 @@ public function scheduleTasks() $task->save(); } break; - case 'SCHEDULED_JOB': + case 'SCHEDULED_JOB': $this->executeScheduledJob($config); $task->last_execution = $today->format('Y-m-d H:i:s'); $task->save(); diff --git a/ProcessMaker/Models/DevLink.php b/ProcessMaker/Models/DevLink.php index a157d7f8fd..e7ad53939d 100644 --- a/ProcessMaker/Models/DevLink.php +++ b/ProcessMaker/Models/DevLink.php @@ -191,14 +191,14 @@ public function installRemoteBundle($remoteBundleId, $updateType) $this->logger->setStatus('done'); } - public function installRemoteAsset(string $class, int $id, Logger $logger) : ProcessMakerModel + public function installRemoteAsset(string $class, int $id, string $updateType, Logger $logger) : ProcessMakerModel { $payload = $this->client()->get( route('api.devlink.export-local-asset', ['class' => $class, 'id' => $id], false) )->json(); $options = new Options([ - 'mode' => 'update', + 'mode' => $updateType, ]); $logger->setSteps([$payload]); diff --git a/ProcessMaker/Models/FormalExpression.php b/ProcessMaker/Models/FormalExpression.php index 0f42344016..838944d60e 100644 --- a/ProcessMaker/Models/FormalExpression.php +++ b/ProcessMaker/Models/FormalExpression.php @@ -239,8 +239,23 @@ function ($__data, $user_id, $assigned_groups) { // If no manager is found, then assign the task to the Process Manager. $request = ProcessRequest::find($__data['_request']['id']); $process = $request->processVersion; + $managers = $process->manager_id ?? []; - return $process->manager_id; + if (empty($managers)) { + return null; + } + + // Sort managers to ensure consistent round robin distribution + sort($managers); + + // Use a combination of process ID and request ID for better distribution + // This ensures different processes don't interfere with each other's round robin + $processId = $process->id ?? 0; + $requestId = $__data['_request']['id'] ?? 0; + $seed = $processId + $requestId; + $managerIndex = $seed % count($managers); + + return $managers[$managerIndex]; } return $user->manager_id; diff --git a/ProcessMaker/Models/GroupMember.php b/ProcessMaker/Models/GroupMember.php index 9ce5a2e8ce..8303513aca 100644 --- a/ProcessMaker/Models/GroupMember.php +++ b/ProcessMaker/Models/GroupMember.php @@ -2,6 +2,8 @@ namespace ProcessMaker\Models; +use ProcessMaker\Observers\GroupMemberObserver; + /** * Represents a group Members definition. * @@ -83,6 +85,30 @@ class GroupMember extends ProcessMakerModel 'group_id', 'member_id', 'member_type', ]; + /** + * Disable soft deletes for this model since the table doesn't have deleted_at column + */ + public function getDeletedAtColumn() + { + return null; + } + + /** + * Disable soft deletes for this model + */ + public static function bootSoftDeletes() + { + // Do nothing - disable soft deletes + } + + /** + * Override the query builder to not use soft deletes + */ + public function newEloquentBuilder($query) + { + return new \Illuminate\Database\Eloquent\Builder($query); + } + public static function rules() { return [ @@ -101,4 +127,14 @@ public function group() { return $this->belongsTo(Group::class); } + + /** + * Boot the model and register observers + */ + protected static function boot() + { + parent::boot(); + + static::observe(GroupMemberObserver::class); + } } diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index 1a7b5da4b6..e7adbba6d8 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -86,7 +86,7 @@ * @OA\Property(property="self_service_tasks", type="object"), * @OA\Property(property="signal_events", type="array", @OA\Items(type="object")), * @OA\Property(property="category", type="object", @OA\Schema(ref="#/components/schemas/ProcessCategory")), - * @OA\Property(property="manager_id", type="integer", format="id"), + * @OA\Property(property="manager_id", type="array", @OA\Items(type="integer", format="id")), * ), * @OA\Schema( * schema="Process", @@ -589,7 +589,7 @@ public function collaborations() * Get the user to whom to assign a task. * * @param ActivityInterface $activity - * @param TokenInterface $token + * @param ProcessRequestToken $token * * @return User */ @@ -613,14 +613,14 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to if ($userByRule !== null) { $user = $this->scalateToManagerIfEnabled($userByRule->id, $activity, $token, $assignmentType); - return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null); + return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null, $token); } } if (filter_var($assignmentLock, FILTER_VALIDATE_BOOLEAN) === true) { $user = $this->getLastUserAssignedToTask($activity->getId(), $token->getInstance()->getId()); if ($user) { - return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, User::where('id', $user)->first()); + return $this->checkAssignment($token->processRequest, $activity, $assignmentType, $escalateToManager, User::where('id', $user)->first(), $token); } } @@ -665,7 +665,7 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to $user = $this->scalateToManagerIfEnabled($user, $activity, $token, $assignmentType); - return $this->checkAssignment($token->getInstance(), $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null); + return $this->checkAssignment($token->getInstance(), $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null, $token); } /** @@ -676,10 +676,11 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to * @param string $assignmentType * @param bool $escalateToManager * @param User|null $user + * @param ProcessRequestToken $token * * @return User|null */ - private function checkAssignment(ProcessRequest $request, ActivityInterface $activity, $assignmentType, $escalateToManager, User $user = null) + private function checkAssignment(ProcessRequest $request, ActivityInterface $activity, $assignmentType, $escalateToManager, User $user = null, ProcessRequestToken $token = null) { $config = $activity->getProperty('config') ? json_decode($activity->getProperty('config'), true) : []; $selfServiceToggle = array_key_exists('selfService', $config ?? []) ? $config['selfService'] : false; @@ -693,10 +694,15 @@ private function checkAssignment(ProcessRequest $request, ActivityInterface $act if ($isSelfService && !$escalateToManager) { return null; } - $user = $request->processVersion->manager; + $rule = new ProcessManagerAssigned(); + if ($token === null) { + throw new ThereIsNoProcessManagerAssignedException($activity); + } + $user = $rule->getNextUser($activity, $token, $this, $request); if (!$user) { throw new ThereIsNoProcessManagerAssignedException($activity); } + $user = User::find($user); } return $user; @@ -1147,7 +1153,7 @@ public function getStartEvents($filterWithPermissions = false, $filterWithoutAss } } } elseif (isset($startEvent['assignment']) && $startEvent['assignment'] === 'process_manager') { - $access = $this->manager && $this->manager->id && $this->manager->id === $user->id; + $access = in_array($user->id, $this->manager_id ?? []); } else { $access = false; } diff --git a/ProcessMaker/Models/ProcessRequest.php b/ProcessMaker/Models/ProcessRequest.php index a75db1a87f..57509fe823 100644 --- a/ProcessMaker/Models/ProcessRequest.php +++ b/ProcessMaker/Models/ProcessRequest.php @@ -280,7 +280,7 @@ public function getNotifiableUserIds($notifiableType) case 'participants': return $this->participants()->get()->pluck('id'); case 'manager': - return collect([$this->process()->first()->manager_id]); + return collect($this->process()->first()->manager_id ?? []); default: return collect([]); } diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 082a2fa3ed..6abf47e02d 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -30,6 +30,7 @@ use ProcessMaker\Traits\HasUuids; use ProcessMaker\Traits\HideSystemResources; use ProcessMaker\Traits\SerializeToIso8601; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Throwable; /** @@ -256,7 +257,7 @@ public function getNotifiableUserIds($notifiableType) case 'manager': $process = $this->process()->first(); - return collect([$process?->manager_id]); + return collect($process?->manager_id ?? []); break; default: return collect([]); @@ -968,6 +969,66 @@ public function getAssignmentRule() return $assignment; } + /** + * Get the assignees for the token. + * + * @param array $assignments + * @param array $variables + * @return array + */ + public function getAssignees(array $assignments, array $variables): array + { + $result = []; + $language = new ExpressionLanguage(); + + foreach ($assignments as $assignment) { + $isTrue = false; + + if (!empty($assignment['expression'])) { + try { + $isTrue = $language->evaluate($assignment['expression'], $variables); + } catch (Throwable $e) { + $isTrue = false; + } + } + + if ($isTrue) { + $result[] = $assignment['assignee']; + } + + if (isset($assignment['default']) && $assignment['default'] === true) { + $result[] = $assignment['assignee']; + } + } + + return $result; + } + + /** + * Get the assignees from the expression + * + * @param string $form_data + * @return array + */ + public function getAssigneesFromExpression(string $form_data): array + { + $formData = json_decode($form_data, true); + + $activity = $this->getBpmnDefinition()->getBpmnElementInstance(); + $assignmentRules = $activity->getProperty('assignmentRules', null); + $assignments = json_decode($assignmentRules, true); + + $include_ids = $this->getAssignees($assignments, $formData); + + // we add the manager to the list of assignees + $manager_id = $this->process->manager_id; + if ($manager_id) { + $include_ids[] = $manager_id; + } + + return $include_ids; + } + /** * Returns if the token has the self service option activated */ diff --git a/ProcessMaker/Multitenancy/MakeQueueTenantAwareAction.php b/ProcessMaker/Multitenancy/MakeQueueTenantAwareAction.php index bfb3ff10a5..25118798fd 100644 --- a/ProcessMaker/Multitenancy/MakeQueueTenantAwareAction.php +++ b/ProcessMaker/Multitenancy/MakeQueueTenantAwareAction.php @@ -2,26 +2,16 @@ namespace ProcessMaker\Multitenancy; -use Illuminate\Queue\Events\JobProcessing; -use Illuminate\Queue\Events\JobRetryRequested; -use Illuminate\Support\Facades\Context; use Spatie\Multitenancy\Actions\MakeQueueTenantAwareAction as BaseMakeQueueTenantAwareAction; class MakeQueueTenantAwareAction extends BaseMakeQueueTenantAwareAction { - /* - * We need to override this method because spatie will throw an error if the tenant is not found. - * However, we want to support non-multitenant instances. If the tenant is not found, - * run the job without a tenant. - */ - protected function bindOrForgetCurrentTenant(JobProcessing|JobRetryRequested $event): void + /** + * We're handling tenant aware queues manually, however, we still need to implement this because for some + * reason the Spatie package calls it in Multitenancy::start(), weather it's a configured action or not. + */ + public function execute() : void { - $tenantId = Context::get($this->currentTenantContextKey()); - if (!$tenantId) { - // No need to do anything. Let the job run without a tenant. - return; - } - - parent::bindOrForgetCurrentTenant($event); + // Do nothing } } diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index e522798a8c..eb5e319881 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,19 @@ 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 +45,38 @@ 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 ($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 1b87dd5d5e..4b2b9f091a 100644 --- a/ProcessMaker/Multitenancy/Tenant.php +++ b/ProcessMaker/Multitenancy/Tenant.php @@ -2,14 +2,43 @@ namespace ProcessMaker\Multitenancy; +use Illuminate\Support\Facades\Crypt; +use ProcessMaker\Application; use Spatie\Multitenancy\Models\Tenant as SpatieTenant; class Tenant extends SpatieTenant { + const BOOTSTRAPPED_TENANT = 'bootstrappedTenant'; + 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() + { + 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] ?? null; + } } diff --git a/ProcessMaker/Multitenancy/TenantAwareDispatcher.php b/ProcessMaker/Multitenancy/TenantAwareDispatcher.php deleted file mode 100644 index 62a20787c6..0000000000 --- a/ProcessMaker/Multitenancy/TenantAwareDispatcher.php +++ /dev/null @@ -1,31 +0,0 @@ -queueResolver); // we need to pass the queueResolver - $this->tenantId = $tenantId; - } - - public function dispatchToQueue($command) - { - $queue = $command->queue; - - // We need to set the default queue here - // because prepending the tenant id means - // it will no longer be empty. - if (empty($queue)) { - $queue = 'default'; - } - $command->queue = 'tenant-' . $this->tenantId . '-' . $queue; - - return parent::dispatchToQueue($command); - } -} diff --git a/ProcessMaker/Multitenancy/TenantBootstrapper.php b/ProcessMaker/Multitenancy/TenantBootstrapper.php new file mode 100644 index 0000000000..629e45e346 --- /dev/null +++ b/ProcessMaker/Multitenancy/TenantBootstrapper.php @@ -0,0 +1,195 @@ +bootstrapRun($app); + } catch (\Exception $e) { + file_put_contents(storage_path('logs/tenant_bootstrapper_error.log'), date('Y-m-d H:i:s') . ' ' . get_class($e) . ' in ' . $e->getFile() . ':' . $e->getLine() . ' ' . $e->getMessage() . PHP_EOL, FILE_APPEND); + throw $e; + } + } + + public function bootstrapRun(Application $app) + { + if (!$this->env('MULTITENANCY')) { + return; + } + $this->app = $app; + + $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); + + return; + } + + // Set storage path + $app->useStoragePath($app->basePath('storage/tenant_' . $tenantData['id'])); + + $this->setTenantEnvironmentVariables($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'] = self::$landlordValues; + Tenant::setBootstrappedTenant($app, $tenantData); + } + + private function setTenantEnvironmentVariables($tenantData) + { + // Additional configs are set in SwitchTenant.php + + $config = json_decode($tenantData['config'], true); + + $this->set('APP_CONFIG_CACHE', $this->app->storagePath('config.php')); + $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')); + + // Do not set REDIS_PREFIX because it is used by the queue (not tenant specific) + $this->set('CACHE_PREFIX', 'tenant_' . $tenantData['id'] . ':' . $this->getOriginalValue('CACHE_PREFIX')); + $this->set('CACHE_SETTING_PREFIX', 'tenant_' . $tenantData['id'] . ':' . $this->getOriginalValue('CACHE_SETTING_PREFIX')); + + $encryptedPassword = $tenantData['password']; + $password = null; + if ($encryptedPassword) { + $password = $this->decrypt($encryptedPassword); + } else { + $password = $this->getOriginalValue('DB_PASSWORD'); + } + + $this->set('DB_PASSWORD', $password); + $this->set('LOG_PATH', $this->app->storagePath('logs/processmaker.log')); + } + + private function getOriginalValue($key, $default = '') + { + if (self::$landlordValues === []) { + self::$landlordValues = Dotenv::parse(file_get_contents(base_path('.env'))); + } + + if (!isset(self::$landlordValues[$key])) { + return $default; + } + + return self::$landlordValues[$key]; + } + + private function env($key, $default = null) + { + $value = $_SERVER[$key] ?? $default; + if ($value === 'true') { + $value = true; + } elseif ($value === 'false') { + $value = false; + } + + return $value; + } + + private function set($key, $value) + { + // Env::getRepository() is immutable but will use values from $_SERVER and $_ENV + $_SERVER[$key] = $value; + $_ENV[$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'); + } + + return $this->encrypter->decryptString($value); + } + + private function getLandlordDbConfig(): array + { + return [ + 'host' => $this->getOriginalValue('DB_HOSTNAME', 'localhost'), + 'port' => $this->getOriginalValue('DB_PORT', '3306'), + 'database' => $this->getOriginalValue('LANDLORD_DB_DATABASE', 'landlord'), + 'username' => $this->getOriginalValue('DB_USERNAME'), + 'password' => $this->getOriginalValue('DB_PASSWORD'), + 'charset' => 'utf8mb4', + ]; + } + + private function getPdo(): PDO + { + if (!$this->pdo) { + $landlordConfig = $this->getLandlordDbConfig(); + $dsn = "mysql:host={$landlordConfig['host']};port={$landlordConfig['port']};dbname={$landlordConfig['database']};charset={$landlordConfig['charset']}"; + $this->pdo = new PDO($dsn, $landlordConfig['username'], $landlordConfig['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + + return $this->pdo; + } + + private function executeQuery($query, $params = []) + { + $stmt = $this->getPdo()->prepare($query); + $stmt->execute($params); + + return $stmt->fetch(); + } +} diff --git a/ProcessMaker/Multitenancy/TenantFinder.php b/ProcessMaker/Multitenancy/TenantFinder.php index bfc986f17c..5dcbb801f9 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,14 @@ class TenantFinder extends DomainTenantFinder { public function findForRequest(Request $request): ?IsTenant { + if (!config('app.multitenancy')) { + return null; + } + + if ($tenant = Tenant::fromBootstrapper()) { + return $tenant; + } + $tenant = null; $message = null; diff --git a/ProcessMaker/Observers/GroupMemberObserver.php b/ProcessMaker/Observers/GroupMemberObserver.php new file mode 100644 index 0000000000..05e763f458 --- /dev/null +++ b/ProcessMaker/Observers/GroupMemberObserver.php @@ -0,0 +1,83 @@ +member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) added to group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'added', $groupMember)); + } + } + } + + /** + * Handle the GroupMember "updated" event. + */ + public function updated(GroupMember $groupMember): void + { + // Only handle group-to-group relationships, not user-to-group + if ($groupMember->member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) membership updated in group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'updated', $groupMember)); + } + } + } + + /** + * Handle the GroupMember "deleted" event. + */ + public function deleted(GroupMember $groupMember): void + { + // Only handle group-to-group relationships, not user-to-group + if ($groupMember->member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) removed from group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'removed', $groupMember)); + } + } + } + + /** + * Handle the GroupMember "restored" event. + */ + public function restored(GroupMember $groupMember): void + { + // Only handle group-to-group relationships, not user-to-group + if ($groupMember->member_type === Group::class) { + $group = Group::find($groupMember->member_id); + $parentGroup = Group::find($groupMember->group_id); + + if ($group && $parentGroup) { + Log::info("Group {$group->name} (ID: {$group->id}) restored to group {$parentGroup->name} (ID: {$parentGroup->id})"); + + event(new GroupMembershipChanged($group, $parentGroup, 'added', $groupMember)); + } + } + } +} diff --git a/ProcessMaker/Observers/SettingObserver.php b/ProcessMaker/Observers/SettingObserver.php index 6d56af975a..e4144ab3fb 100644 --- a/ProcessMaker/Observers/SettingObserver.php +++ b/ProcessMaker/Observers/SettingObserver.php @@ -10,6 +10,8 @@ class SettingObserver { + private static $added_refresh_artisan_caches = false; + /** * Handle the setting "created" event. * @@ -91,6 +93,19 @@ private function invalidateSettingCache(Setting $setting) $key = $settingCache->createKey(['key' => $setting->key]); $settingCache->invalidate(['key' => $key]); - RefreshArtisanCaches::dispatch(); + // Check to see if we already added the refresh to the app's terminating queue. + // This is important for install commands when multiple settings are being created/updated. + if (self::$added_refresh_artisan_caches) { + return; + } + + // Use app()->terminating to ensure the cache is refreshed after the settings have been saved. + app()->terminating(function () { + // Do this synchronously so we dont have to wait after settings have been saved. + // This command runs pretty fast and only when an admin is updating settings. + RefreshArtisanCaches::dispatchSync(); + }); + + self::$added_refresh_artisan_caches = true; } } diff --git a/ProcessMaker/Policies/ProcessPolicy.php b/ProcessMaker/Policies/ProcessPolicy.php index efe717c0a1..192fa02a75 100644 --- a/ProcessMaker/Policies/ProcessPolicy.php +++ b/ProcessMaker/Policies/ProcessPolicy.php @@ -17,7 +17,7 @@ class ProcessPolicy * Run before all methods to determine if the * user is an admin and can do everything. * - * @param \ProcessMaker\Models\User $user + * @param User $user * @return mixed */ public function before(User $user) @@ -39,8 +39,8 @@ public function edit(User $user, Process $process) /** * Determine whether the user can start the process. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\Process $process + * @param User $user + * @param Process $process * @return mixed */ public function start(User $user, Process $process) @@ -60,7 +60,7 @@ public function start(User $user, Process $process) $userCanStartAsProcessManager = array_reduce($process->getStartEvents(), function ($carry, $item) use ($process, $user) { if (array_key_exists('assignment', $item)) { - $carry = $carry || ($item['assignment'] === 'process_manager' && $process->manager_id === $user->id); + $carry = $carry || ($item['assignment'] === 'process_manager' && in_array($user->id, $process->manager_id ?? [])); } return $carry; @@ -81,8 +81,8 @@ function ($carry, $item) use ($process, $user) { /** * Determine whether the user can cancel the process. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\Process $process + * @param User $user + * @param Process $process * * @return bool */ @@ -99,7 +99,7 @@ public function cancel(User $user, Process $process) } if ( - $process->manager_id === $user->id && + in_array($user->id, $process->manager_id ?? []) && $process->getProperty('manager_can_cancel_request') === true ) { return true; @@ -111,8 +111,8 @@ public function cancel(User $user, Process $process) /** * Determine whether the user can edit data. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\Process $process + * @param User $user + * @param Process $process * * @return bool */ diff --git a/ProcessMaker/Policies/ProcessRequestTokenPolicy.php b/ProcessMaker/Policies/ProcessRequestTokenPolicy.php index b6d47a3dea..ff51cf7556 100644 --- a/ProcessMaker/Policies/ProcessRequestTokenPolicy.php +++ b/ProcessMaker/Policies/ProcessRequestTokenPolicy.php @@ -38,7 +38,7 @@ public function before(User $user) public function view(User $user, ProcessRequestToken $processRequestToken) { if ($processRequestToken->user_id == $user->id || - $processRequestToken->process?->manager_id === $user->id + in_array($user->id, $processRequestToken->process?->manager_id ?? []) ) { return true; } @@ -59,7 +59,7 @@ public function update(User $user, ProcessRequestToken $processRequestToken) if ( $processRequestToken->user_id === $user->id || $processRequestToken->user_id === app(AnonymousUser::class)->id || - $processRequestToken->process?->manager_id === $user->id + in_array($user->id, $processRequestToken->process?->manager_id ?? []) ) { return true; } @@ -90,13 +90,13 @@ public function viewScreen(User $user, ProcessRequestToken $task): bool public function rollback(User $user, ProcessRequestToken $task) { // For now, only the process manager can rollback the request - return $user->id === $task->process->managerId; + return in_array($user->id, $task->process?->manager_id ?? []); } public function reassign(User $user, ProcessRequestToken $task) { // If user is process manager - if ($user->id === $task->process->managerId) { + if (in_array($user->id, $task->process?->manager_id ?? [])) { return true; } diff --git a/ProcessMaker/Policies/ProcessVersionPolicy.php b/ProcessMaker/Policies/ProcessVersionPolicy.php index d8a653df90..b7b93ba54a 100644 --- a/ProcessMaker/Policies/ProcessVersionPolicy.php +++ b/ProcessMaker/Policies/ProcessVersionPolicy.php @@ -14,7 +14,7 @@ class ProcessVersionPolicy * Run before all methods to determine if the * user is an admin and can do everything. * - * @param \ProcessMaker\Models\User $user + * @param User $user * @return mixed */ public function before(User $user) @@ -27,8 +27,8 @@ public function before(User $user) /** * Determine whether the user can cancel the process version. * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\ProcessVersion $processVersion + * @param User $user + * @param ProcessVersion $processVersion * * @return bool */ @@ -45,7 +45,7 @@ public function cancel(User $user, ProcessVersion $processVersion) } if ( - $processVersion->manager_id === $user->id && + in_array($user->id, $processVersion->manager_id ?? []) && $processVersion->getProperty('manager_can_cancel_request') === true ) { return true; @@ -57,8 +57,8 @@ public function cancel(User $user, ProcessVersion $processVersion) /** * Determine whether the user can edit data * - * @param \ProcessMaker\Models\User $user - * @param \ProcessMaker\Models\ProcessVersion $processVersion + * @param User $user + * @param ProcessVersion $processVersion * * @return bool */ diff --git a/ProcessMaker/Providers/EventServiceProvider.php b/ProcessMaker/Providers/EventServiceProvider.php index ec8b5fc71e..f2cc0c0832 100644 --- a/ProcessMaker/Providers/EventServiceProvider.php +++ b/ProcessMaker/Providers/EventServiceProvider.php @@ -23,6 +23,7 @@ use ProcessMaker\Events\FilesUpdated; use ProcessMaker\Events\GroupCreated; use ProcessMaker\Events\GroupDeleted; +use ProcessMaker\Events\GroupMembershipChanged; use ProcessMaker\Events\GroupUpdated; use ProcessMaker\Events\GroupUsersUpdated; use ProcessMaker\Events\PermissionUpdated; @@ -65,6 +66,8 @@ use ProcessMaker\Listeners\HandleActivityAssignedInterstitialRedirect; use ProcessMaker\Listeners\HandleActivityCompletedRedirect; use ProcessMaker\Listeners\HandleEndEventRedirect; +use ProcessMaker\Listeners\InvalidatePermissionCacheOnGroupHierarchyChange; +use ProcessMaker\Listeners\InvalidatePermissionCacheOnUpdate; use ProcessMaker\Listeners\InvalidateScreenCacheOnTranslationChange; use ProcessMaker\Listeners\SecurityLogger; use ProcessMaker\Listeners\SessionControlSettingsUpdated; @@ -114,6 +117,12 @@ class EventServiceProvider extends ServiceProvider TranslationChanged::class => [ InvalidateScreenCacheOnTranslationChange::class, ], + PermissionUpdated::class => [ + InvalidatePermissionCacheOnUpdate::class, + ], + GroupMembershipChanged::class => [ + InvalidatePermissionCacheOnGroupHierarchyChange::class, + ], ]; /** diff --git a/ProcessMaker/Providers/PermissionServiceProvider.php b/ProcessMaker/Providers/PermissionServiceProvider.php new file mode 100644 index 0000000000..d5b69ef043 --- /dev/null +++ b/ProcessMaker/Providers/PermissionServiceProvider.php @@ -0,0 +1,31 @@ +app->bind(PermissionRepositoryInterface::class, PermissionRepository::class); + $this->app->bind(PermissionCacheInterface::class, PermissionCacheService::class); + + // Bind the service manager as a singleton + $this->app->singleton(PermissionServiceManager::class, function ($app) { + return new PermissionServiceManager( + $app->make(PermissionRepositoryInterface::class), + $app->make(PermissionCacheInterface::class) + ); + }); + } +} diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index 0e2710709e..ccdb708068 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -9,16 +9,19 @@ use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Notifications\Events\BroadcastNotificationCreated; use Illuminate\Notifications\Events\NotificationSent; +use Illuminate\Queue\Events\JobAttempted; +use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Queue\Events\JobRetryRequested; use Illuminate\Support\Arr; use Illuminate\Support\Env; use Illuminate\Support\Facades; +use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; 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; @@ -38,8 +41,10 @@ use ProcessMaker\Managers\ScreenCompiledManager; use ProcessMaker\Models; use ProcessMaker\Multitenancy\Tenant; +use ProcessMaker\Multitenancy\TenantBootstrapper; use ProcessMaker\Observers; use ProcessMaker\PolicyExtension; +use ProcessMaker\Providers\PermissionServiceProvider; use ProcessMaker\Repositories\SettingsConfigRepository; use RuntimeException; use Spatie\Multitenancy\Events\MadeTenantCurrentEvent; @@ -63,6 +68,12 @@ class ProcessMakerServiceProvider extends ServiceProvider // Track the query time for each request private static $queryTime = 0; + // Track the landlord values for multitenancy + private static $landlordValues = null; + + // Cache tenant app containers to save memory + private static $tenantAppContainers = []; + public function boot(): void { // Track the start time for service providers boot @@ -90,71 +101,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 @@ -172,6 +118,9 @@ public function register(): void $this->app->register(DuskServiceProvider::class); } + // Register our permission services + $this->app->register(PermissionServiceProvider::class); + $this->app->singleton(Managers\PackageManager::class, function () { return new Managers\PackageManager(); }); @@ -259,6 +208,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'), @@ -290,11 +243,82 @@ public function register(): void $this->app->instance('tenant-resolved', false); } + /** + * In multitenancy, we need to bootstrap a new app with the tenant id set. + * This is because queue workers are long-running processes that are not + * tenant aware. + */ + private static function bootstrapTenantApp(JobProcessing|JobRetryRequested $event): void + { + Context::hydrate($event->job->payload()['illuminate:log:context'] ?? null); + $tenantId = Context::get(config('multitenancy.current_tenant_context_key')); + if ($tenantId) { + if (!method_exists($event->job, 'getRedisQueue')) { + // Not a redis job + return; + } + + // Save the landlord's config values so we can reset them later + if (self::$landlordValues === null) { + foreach (TenantBootstrapper::$landlordKeysToSave as $key) { + self::$landlordValues[$key] = $_SERVER[$key] ?? ''; + } + } + + // Create a new tenant app instance + $_SERVER['TENANT'] = $tenantId; + $_ENV['TENANT'] = $tenantId; + + if (!isset(self::$tenantAppContainers[$tenantId])) { + $tenantApp = require app()->bootstrapPath('app.php'); + $tenantApp->instance('landlordValues', self::$landlordValues); + $tenantApp->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + self::$tenantAppContainers[$tenantId] = $tenantApp; + } + + // Change the job's app service container to the tenant app + $event->job->getRedisQueue()->setContainer(self::$tenantAppContainers[$tenantId]); + } + } + + private static function resetTenantApp($event): void + { + if (!method_exists($event->job, 'getRedisQueue')) { + // Not a redis job + return; + } + + unset($_SERVER['TENANT']); + unset($_ENV['TENANT']); + + if (!self::$landlordValues) { + return; + } + + // Restore the original values since the tenant boostrapper modified them + foreach (self::$landlordValues as $key => $value) { + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + } + } + /** * Register app-level events. */ protected static function registerEvents(): void { + Facades\Event::listen(JobProcessing::class, function (JobProcessing $event) { + self::bootstrapTenantApp($event); + }); + + Facades\Event::listen(JobRetryRequested::class, function (JobRetryRequested $event) { + self::bootstrapTenantApp($event); + }); + + Facades\Event::listen(JobAttempted::class, function (JobAttempted $event) { + self::resetTenantApp($event); + }); + // Listen to the events for our core screen // types and add our javascript Facades\Event::listen(ScreenBuilderStarting::class, function ($event) { @@ -587,15 +611,27 @@ private function setCurrentTenantForConsoleCommands(): void return; } - $tenantId = Env::get('TENANT'); - if ($tenantId) { - $tenant = Tenant::findOrFail($tenantId); + if (config('app.multitenancy') === false) { + event(new TenantResolved(null)); + + return; + } + + if ($tenant = Tenant::fromBootstrapper()) { $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. + + return; + } + + $tenantId = Env::get('TENANT'); + if (!$tenantId) { event(new TenantResolved(null)); + + return; } + + $tenant = Tenant::findOrFail($tenantId); + $tenant->makeCurrent(); } /** diff --git a/ProcessMaker/Providers/TenantQueueServiceProvider.php b/ProcessMaker/Providers/TenantQueueServiceProvider.php index 60bd89c45b..b603718e2d 100644 --- a/ProcessMaker/Providers/TenantQueueServiceProvider.php +++ b/ProcessMaker/Providers/TenantQueueServiceProvider.php @@ -6,10 +6,14 @@ use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Queue\Events\JobQueued; +use Illuminate\Queue\Events\JobQueueing; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Redis; use Illuminate\Support\ServiceProvider; +use Laravel\Horizon\Events\JobPushed; class TenantQueueServiceProvider extends ServiceProvider { @@ -27,9 +31,7 @@ public function register(): void public function boot(): void { // Only register queue event listeners if tenant job tracking is enabled - $enabled = config('queue.tenant_tracking_enabled', false); - - if ($enabled) { + if (self::enabled()) { $this->registerQueueEventListeners(); } } @@ -39,6 +41,10 @@ public function boot(): void */ protected function registerQueueEventListeners(): void { + Event::listen(JobQueued::class, function (JobQueued $event) { + $this->trackJobByTenant($event, 'pushed', $event->id, $event->queue ?? 'default'); + }); + // Job pending Queue::before(function (JobProcessing $event) { $this->trackJobByTenant($event->job, 'pending'); @@ -63,7 +69,7 @@ protected function registerQueueEventListeners(): void /** * Track job by tenant in Redis. */ - protected function trackJobByTenant($job, string $status): void + protected function trackJobByTenant($job, string $status, $jobId = null, $queue = null): void { try { $tenantId = $this->extractTenantIdFromJob($job); @@ -72,7 +78,7 @@ protected function trackJobByTenant($job, string $status): void return; } - $jobId = $job->getJobId(); + $jobId = $jobId ?? $job->getJobId(); if (!$jobId) { return; } @@ -82,14 +88,14 @@ protected function trackJobByTenant($job, string $status): void $jobData = [ 'id' => $jobId, 'name' => $this->getJobName($job), - 'queue' => $job->getQueue() ?? 'default', + 'queue' => ($queue ?? $job->getQueue()) ?? 'default', 'status' => $status, - 'created_at' => $payload['pushedAt'], + 'created_at' => $status === 'pushed' ? str_replace(',', '.', microtime(true)) : $payload['pushedAt'], 'completed_at' => $status === 'completed' ? str_replace(',', '.', microtime(true)) : null, 'failed_at' => $status === 'failed' ? str_replace(',', '.', microtime(true)) : null, 'updated_at' => str_replace(',', '.', microtime(true)), 'tenant_id' => $tenantId, - 'attempts' => $job->attempts(), + 'attempts' => $status === 'pushed' ? 0 : $job->attempts(), 'payload' => json_encode($payload['data']), ]; @@ -97,6 +103,10 @@ protected function trackJobByTenant($job, string $status): void $jobData['queued_at'] = str_replace(',', '.', microtime(true)); } + if ($status === 'pushed') { + $jobData['pushed_at'] = str_replace(',', '.', microtime(true)); + } + // Check if job already exists $tenantKey = "tenant_jobs:{$tenantId}:{$jobId}"; $existingJobData = Redis::get($tenantKey); @@ -230,7 +240,7 @@ protected function updateTenantJobCounters(string $tenantId, string $status, str */ protected function handleStatusTransition(string $tenantId, string $jobId, string $newStatus): void { - $statuses = ['pending', 'completed', 'failed', 'exception']; + $statuses = ['pushed', 'pending', 'completed', 'failed', 'exception']; foreach ($statuses as $status) { if ($status === $newStatus) { @@ -276,7 +286,7 @@ protected function updateTenantJobLists(string $tenantId, string $jobId, string } else { // If job is already in the list, move it to the front (most recent) Redis::pipeline(function ($pipe) use ($listKey, $jobId) { - $pipe->lrem($listKey, 0, $jobId); + $pipe->lrem($listKey, $jobId, 0); $pipe->lpush($listKey, $jobId); $pipe->expire($listKey, 86400); // Expire in 24 hours }); @@ -306,7 +316,7 @@ public static function getTenantJobs(string $tenantId, string $status = null, in $jobIds = Redis::lrange($listKey, 0, $limit - 1); } else { // Get jobs from all statuses - $statuses = ['pending', 'completed', 'failed', 'exception']; + $statuses = ['pushed', 'pending', 'completed', 'failed', 'exception']; $jobIds = []; foreach ($statuses as $status) { @@ -345,6 +355,7 @@ public static function getTenantJobStats(string $tenantId): array return [ 'total' => (int) ($counters['total'] ?? 0), + 'pushed' => (int) ($counters['pushed'] ?? 0), 'pending' => (int) ($counters['pending'] ?? 0), 'completed' => (int) ($counters['completed'] ?? 0), 'failed' => (int) ($counters['failed'] ?? 0), @@ -362,7 +373,7 @@ public static function getTenantsWithJobs(): array $tenants = []; foreach ($keys as $key) { - $tenantId = str_replace('tenant_job_counters:', '', $key); + $tenantId = preg_replace('/^.*:/', '', $key); $stats = self::getTenantJobStats($tenantId); if ($stats['total'] > 0) { @@ -375,4 +386,23 @@ public static function getTenantsWithJobs(): array return $tenants; } + + public static function enabled(): bool + { + if (!config('app.multitenancy')) { + return false; + } + + return !config('queue.disable_tenant_tracking'); + } + + public static function allowAllTenats(): bool + { + if (!config('app.multitenancy')) { + // Only restricted in multitenant environments + return true; + } + + return config('queue.ui_allow_all_tenants'); + } } diff --git a/ProcessMaker/Repositories/CaseApiRepository.php b/ProcessMaker/Repositories/CaseApiRepository.php index 4d4918217f..0f18942375 100644 --- a/ProcessMaker/Repositories/CaseApiRepository.php +++ b/ProcessMaker/Repositories/CaseApiRepository.php @@ -34,7 +34,7 @@ class CaseApiRepository implements CaseApiRepositoryInterface 'completed_at', 'last_stage_id', 'last_stage_name', - 'progress' + 'progress', ]; protected $sortableFields = [ diff --git a/ProcessMaker/Repositories/PermissionRepository.php b/ProcessMaker/Repositories/PermissionRepository.php new file mode 100644 index 0000000000..694bece228 --- /dev/null +++ b/ProcessMaker/Repositories/PermissionRepository.php @@ -0,0 +1,264 @@ +find($userId); + + if (!$user) { + return []; + } + + $permissions = []; + + // Add direct user permissions + if ($user->permissions) { + foreach ($user->permissions as $permission) { + $permissions[] = $permission->name; + } + } + + // Add group permissions (including nested groups through recursion) + foreach ($user->groupMembersFromMemberable as $groupMember) { + $group = $groupMember->group; + if ($group && $group->permissions) { + foreach ($group->permissions as $permission) { + $permissions[] = $permission->name; + } + + // Get nested group permissions recursively + $nestedPermissions = $this->getNestedGroupPermissionsOptimized($group); + $permissions = array_merge($permissions, $nestedPermissions); + } + } + + return array_unique($permissions); + } + + /** + * Get direct user permissions + */ + public function getDirectUserPermissions(int $userId): array + { + $user = User::find($userId); + if (!$user) { + return []; + } + + return $user->permissions()->pluck('name')->toArray(); + } + + /** + * Get group permissions for a user (optimized with hierarchical inheritance) + */ + public function getGroupPermissions(int $userId): array + { + $user = User::with([ + 'groupMembersFromMemberable.group.permissions', + ])->find($userId); + + if (!$user) { + return []; + } + + $permissions = []; + + // Add group permissions (including nested groups through recursion) + foreach ($user->groupMembersFromMemberable as $groupMember) { + $group = $groupMember->group; + if ($group && $group->permissions) { + foreach ($group->permissions as $permission) { + $permissions[] = $permission->name; + } + + // Get nested group permissions recursively + $nestedPermissions = $this->getNestedGroupPermissionsOptimized($group); + $permissions = array_merge($permissions, $nestedPermissions); + } + } + + return array_unique($permissions); + } + + /** + * Check if user has a specific permission (optimized with hierarchical inheritance) + */ + public function userHasPermission(int $userId, string $permission): bool + { + $user = User::with([ + 'permissions', + 'groupMembersFromMemberable.group.permissions', + ])->find($userId); + + if (!$user) { + return false; + } + + // Check direct user permissions + if ($user->permissions) { + foreach ($user->permissions as $userPermission) { + if ($userPermission->name === $permission) { + return true; + } + } + } + + // Check group permissions (including nested groups through recursion) + foreach ($user->groupMembersFromMemberable as $groupMember) { + $group = $groupMember->group; + if ($group) { + // Check direct group permissions + if ($group->permissions) { + foreach ($group->permissions as $groupPermission) { + if ($groupPermission->name === $permission) { + return true; + } + } + } + + // Check nested group permissions recursively + if ($this->hasNestedGroupPermission($group, $permission)) { + return true; + } + } + } + + return false; + } + + /** + * Get permissions for a specific group + */ + public function getGroupPermissionsById(int $groupId): array + { + $group = Group::find($groupId); + if (!$group) { + return []; + } + + return $group->permissions()->pluck('name')->toArray(); + } + + /** + * Get nested group permissions (optimized recursive) + */ + public function getNestedGroupPermissions(int $groupId): array + { + $group = Group::find($groupId); + if (!$group) { + return []; + } + + $permissions = []; + + // Get direct group permissions + $groupPermissions = $group->permissions()->pluck('name')->toArray(); + $permissions = array_merge($permissions, $groupPermissions); + + // Get nested group permissions recursively from parent groups + foreach ($group->groupMembersFromMemberable as $member) { + if ($member->member_type === Group::class && $member->group) { + // Recurse on the parent group (group_id), not the current group (member_id) + $nestedPermissions = $this->getNestedGroupPermissions($member->group->id); + $permissions = array_merge($permissions, $nestedPermissions); + } + } + + return array_unique($permissions); + } + + /** + * Get nested group permissions recursively with protection against infinite loops + */ + private function getNestedGroupPermissionsOptimized(Group $group, array $visitedGroups = [], int $maxDepth = 10): array + { + // Protection against infinite recursion + if (in_array($group->id, $visitedGroups) || count($visitedGroups) >= $maxDepth) { + return []; + } + + $permissions = []; + $newVisitedGroups = array_merge($visitedGroups, [$group->id]); + + // Get groups that have this group as a member + // Load the relationship if not already loaded + if (!$group->relationLoaded('groupMembersFromMemberable')) { + $group->load(['groupMembersFromMemberable.group.permissions']); + } + + foreach ($group->groupMembersFromMemberable as $member) { + if ($member->member_type === Group::class && $member->group) { + $nestedGroup = $member->group; + + // Add direct permissions from nested group + if ($nestedGroup->permissions) { + foreach ($nestedGroup->permissions as $permission) { + $permissions[] = $permission->name; + } + } + + // Recursively get permissions from deeper nested groups + $deeperPermissions = $this->getNestedGroupPermissionsOptimized($nestedGroup, $newVisitedGroups, $maxDepth); + $permissions = array_merge($permissions, $deeperPermissions); + } + } + + return $permissions; + } + + /** + * Check if a group has a specific permission through nested inheritance + */ + private function hasNestedGroupPermission(Group $group, string $permission, array $visitedGroups = [], int $maxDepth = 10): bool + { + // Protection against infinite recursion + if (in_array($group->id, $visitedGroups) || count($visitedGroups) >= $maxDepth) { + return false; + } + + $newVisitedGroups = array_merge($visitedGroups, [$group->id]); + + // Load the relationship if not already loaded + if (!$group->relationLoaded('groupMembersFromMemberable')) { + $group->load(['groupMembersFromMemberable.group.permissions']); + } + + foreach ($group->groupMembersFromMemberable as $member) { + if ($member->member_type === Group::class && $member->group) { + $nestedGroup = $member->group; + + // Check direct permissions from nested group + if ($nestedGroup->permissions) { + foreach ($nestedGroup->permissions as $groupPermission) { + if ($groupPermission->name === $permission) { + return true; + } + } + } + + // Recursively check permissions from deeper nested groups + if ($this->hasNestedGroupPermission($nestedGroup, $permission, $newVisitedGroups, $maxDepth)) { + return true; + } + } + } + + return false; + } +} diff --git a/ProcessMaker/Services/MetricsService.php b/ProcessMaker/Services/MetricsService.php index 20ae9b8862..824f12b6cc 100644 --- a/ProcessMaker/Services/MetricsService.php +++ b/ProcessMaker/Services/MetricsService.php @@ -3,13 +3,18 @@ namespace ProcessMaker\Services; use Exception; +use Laravel\Horizon\Contracts\JobRepository; +use Laravel\Horizon\Contracts\MetricsRepository; +use Laravel\Horizon\Contracts\WorkloadRepository; use ProcessMaker\Facades\Metrics; +use ProcessMaker\Multitenancy\Tenant; use Prometheus\CollectorRegistry; use Prometheus\Counter; use Prometheus\Gauge; use Prometheus\Histogram; use Prometheus\RenderTextFormat; -use Prometheus\Storage\Redis; +use Prometheus\Storage\Redis as PrometheusRedis; +use Redis; use RuntimeException; class MetricsService @@ -39,7 +44,12 @@ public function __construct(private $adapter = null) try { // Set up Redis as the adapter if none is provided if ($adapter === null) { - $adapter = Redis::fromExistingConnection(app('redis')->client()); + $redis = app('redis')->client(); + $adapter = PrometheusRedis::fromExistingConnection($redis); + if (app()->has(Tenant::BOOTSTRAPPED_TENANT)) { + $tenantInfo = app(Tenant::BOOTSTRAPPED_TENANT); + $adapter->setPrefix('tenant_' . $tenantInfo['id'] . ':PROMETHEUS_'); + } } $this->collectionRegistry = new CollectorRegistry($adapter); } catch (Exception $e) { @@ -65,7 +75,7 @@ public function getCollectionRegistry(): CollectorRegistry * @param array $labels The labels of the counter. * @return Counter The registered or retrieved counter. */ - public function counter(string $name, string $help = null, array $labels = []): Counter + public function counter(string $name, string|null $help = null, array $labels = []): Counter { $help = $help ?? $name; @@ -85,7 +95,7 @@ public function counter(string $name, string $help = null, array $labels = []): * @param array $labels The labels of the gauge. * @return Gauge The registered or retrieved gauge. */ - public function gauge(string $name, string $help = null, array $labels = []): Gauge + public function gauge(string $name, string|null $help = null, array $labels = []): Gauge { $help = $help ?? $name; @@ -201,7 +211,9 @@ public function addSystemLabels(array $labels) // Add system labels $labels['app_version'] = $this->getApplicationVersion(); $labels['app_name'] = config('app.name'); - $labels['app_custom_label'] = config('app.prometheus_custom_label'); + if (config('app.prometheus_custom_label')) { + $labels['app_custom_label'] = config('app.prometheus_custom_label'); + } return $labels; } @@ -223,4 +235,29 @@ private function getApplicationVersion() return $composer_json_path->version ?? '4.0.0'; } + + /** + * These are collected every time the /metrics route is accessed. + * + * @return void + */ + public function collectQueueMetrics(): void + { + $metricsRepository = app(MetricsRepository::class); + $jobsRepository = app(JobRepository::class); + $workloadRepository = app(WorkloadRepository::class); + + $this->gauge('horizon_jobs_per_minute', 'Jobs processed per minute')->set($metricsRepository->jobsProcessedPerMinute()); + $this->gauge('horizon_failed_jobs_per_hour', 'Failed jobs per hour')->set($jobsRepository->countRecentlyFailed()); + + foreach ($workloadRepository->get() as $workload) { + $name = $workload['name']; + foreach (['length', 'wait', 'processes'] as $type) { + $this->gauge( + 'horizon_workload_' . $name . '_' . $type, + 'Workload ' . $name . ' ' . $type + )->set($workload[$type]); + } + } + } } diff --git a/ProcessMaker/Services/PermissionCacheService.php b/ProcessMaker/Services/PermissionCacheService.php new file mode 100644 index 0000000000..167f7be0e8 --- /dev/null +++ b/ProcessMaker/Services/PermissionCacheService.php @@ -0,0 +1,163 @@ +getUserPermissionsKey($userId); + + try { + return Cache::get($key); + } catch (\Exception $e) { + Log::warning("Failed to get cached user permissions for user {$userId}: " . $e->getMessage()); + + return null; + } + } + + /** + * Cache user permissions + */ + public function cacheUserPermissions(int $userId, array $permissions): void + { + $key = $this->getUserPermissionsKey($userId); + + try { + Cache::put($key, $permissions, self::USER_PERMISSIONS_TTL); + } catch (\Exception $e) { + Log::warning("Failed to cache user permissions for user {$userId}: " . $e->getMessage()); + } + } + + /** + * Get cached permissions for a group + */ + public function getGroupPermissions(int $groupId): ?array + { + $key = $this->getGroupPermissionsKey($groupId); + + try { + return Cache::get($key); + } catch (\Exception $e) { + Log::warning("Failed to get cached group permissions for group {$groupId}: " . $e->getMessage()); + + return null; + } + } + + /** + * Cache group permissions + */ + public function cacheGroupPermissions(int $groupId, array $permissions): void + { + $key = $this->getGroupPermissionsKey($groupId); + + try { + Cache::put($key, $permissions, self::GROUP_PERMISSIONS_TTL); + } catch (\Exception $e) { + Log::warning("Failed to cache group permissions for group {$groupId}: " . $e->getMessage()); + } + } + + /** + * Invalidate user permissions cache + */ + public function invalidateUserPermissions(int $userId): void + { + $key = $this->getUserPermissionsKey($userId); + + try { + Cache::forget($key); + } catch (\Exception $e) { + Log::warning("Failed to invalidate user permissions cache for user {$userId}: " . $e->getMessage()); + } + } + + /** + * Invalidate group permissions cache + */ + public function invalidateGroupPermissions(int $groupId): void + { + $key = $this->getGroupPermissionsKey($groupId); + + try { + Cache::forget($key); + } catch (\Exception $e) { + Log::warning("Failed to invalidate group permissions cache for group {$groupId}: " . $e->getMessage()); + } + } + + /** + * Clear all permission caches + */ + public function clearAll(): void + { + try { + // Clear all permission-related caches + Cache::flush(); + } catch (\Exception $e) { + Log::warning('Failed to clear all permission caches: ' . $e->getMessage()); + } + } + + /** + * Get cache key for user permissions + */ + private function getUserPermissionsKey(int $userId): string + { + return self::USER_PERMISSIONS_KEY . ":{$userId}"; + } + + /** + * Get cache key for group permissions + */ + private function getGroupPermissionsKey(int $groupId): string + { + return self::GROUP_PERMISSIONS_KEY . ":{$groupId}"; + } + + /** + * Warm up cache for a user + */ + public function warmUpUserCache(int $userId, array $permissions): void + { + $this->cacheUserPermissions($userId, $permissions); + } + + /** + * Warm up cache for a group + */ + public function warmUpGroupCache(int $groupId, array $permissions): void + { + $this->cacheGroupPermissions($groupId, $permissions); + } + + /** + * Get cache statistics + */ + public function getCacheStats(): array + { + return [ + 'user_permissions_ttl' => self::USER_PERMISSIONS_TTL, + 'group_permissions_ttl' => self::GROUP_PERMISSIONS_TTL, + 'cache_driver' => config('cache.default'), + ]; + } +} diff --git a/ProcessMaker/Services/PermissionServiceManager.php b/ProcessMaker/Services/PermissionServiceManager.php new file mode 100644 index 0000000000..9d5b53d6fb --- /dev/null +++ b/ProcessMaker/Services/PermissionServiceManager.php @@ -0,0 +1,116 @@ +repository = $repository; + $this->cacheService = $cacheService; + + // Register default strategy + $this->defaultStrategy = new CachedPermissionStrategy($cacheService, $repository); + } + + /** + * Get all permissions for a user + */ + public function getUserPermissions(int $userId): array + { + // Try cache first + $cachedPermissions = $this->cacheService->getUserPermissions($userId); + + if ($cachedPermissions !== null) { + return $cachedPermissions; + } + + // Get from repository and cache + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + + return $permissions; + } + + /** + * Check if user has permission + */ + public function userHasPermission(int $userId, string $permission): bool + { + return $this->defaultStrategy->hasPermission($userId, $permission); + } + + /** + * Check if user has any of the given permissions + */ + public function userHasAnyPermission(int $userId, array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->userHasPermission($userId, $permission)) { + return true; + } + } + + return false; + } + + /** + * Check if user has all of the given permissions + */ + public function userHasAllPermissions(int $userId, array $permissions): bool + { + foreach ($permissions as $permission) { + if (!$this->userHasPermission($userId, $permission)) { + return false; + } + } + + return true; + } + + /** + * Warm up cache for a user + */ + public function warmUpUserCache(int $userId): void + { + try { + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + + Log::info("Warmed up permission cache for user {$userId}"); + } catch (\Exception $e) { + Log::error("Failed to warm up permission cache for user {$userId}: " . $e->getMessage()); + } + } + + /** + * Invalidate cache for a user + */ + public function invalidateUserCache(int $userId): void + { + $this->cacheService->invalidateUserPermissions($userId); + Log::info("Invalidated permission cache for user {$userId}"); + } + + /** + * Invalidate all permission caches + */ + public function invalidateAll(): void + { + $this->cacheService->clearAll(); + } +} diff --git a/ProcessMaker/Services/PermissionStrategies/CachedPermissionStrategy.php b/ProcessMaker/Services/PermissionStrategies/CachedPermissionStrategy.php new file mode 100644 index 0000000000..9d4860c9ee --- /dev/null +++ b/ProcessMaker/Services/PermissionStrategies/CachedPermissionStrategy.php @@ -0,0 +1,75 @@ +cacheService = $cacheService; + $this->repository = $repository; + } + + /** + * Check if user has permission using cached strategy + */ + public function hasPermission(int $userId, string $permission): bool + { + // First, try to get from cache + $cachedPermissions = $this->cacheService->getUserPermissions($userId); + + if ($cachedPermissions !== null) { + return in_array($permission, $cachedPermissions); + } + + // If not in cache, get from repository and cache + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + + return in_array($permission, $permissions); + } + + /** + * Get strategy name for identification + */ + public function getStrategyName(): string + { + return 'cached'; + } + + /** + * Check if this strategy can handle the permission check + */ + public function canHandle(string $permission): bool + { + // This strategy can handle all permission types + return true; + } + + /** + * Warm up cache for a user + */ + public function warmUpCache(int $userId): void + { + $permissions = $this->repository->getUserPermissions($userId); + $this->cacheService->cacheUserPermissions($userId, $permissions); + } + + /** + * Invalidate cache for a user + */ + public function invalidateCache(int $userId): void + { + $this->cacheService->invalidateUserPermissions($userId); + } +} diff --git a/ProcessMaker/Traits/HasAuthorization.php b/ProcessMaker/Traits/HasAuthorization.php index d699b403ae..591102afa2 100644 --- a/ProcessMaker/Traits/HasAuthorization.php +++ b/ProcessMaker/Traits/HasAuthorization.php @@ -6,15 +6,28 @@ use Illuminate\Support\Facades\Log; use ProcessMaker\Models\Group; use ProcessMaker\Models\Permission; +use ProcessMaker\Services\PermissionServiceManager; trait HasAuthorization { + private ?PermissionServiceManager $permissionService = null; + + /** + * Get or create permission service manager + */ + private function getPermissionService(): PermissionServiceManager + { + if ($this->permissionService === null) { + $this->permissionService = app(PermissionServiceManager::class); + } + + return $this->permissionService; + } + public function loadPermissions() { - return array_merge( - $this->loadUserPermissions(), - $this->loadGroupPermissions() - ); + // Use the new optimized service + return $this->getPermissionService()->getUserPermissions($this->id); } public function loadUserPermissions() @@ -74,9 +87,16 @@ public function loadPermissionOfGroups(Group $group, array $permissions = [], ar public function hasPermission($permissionString) { - $permissionStrings = $this->loadPermissions(); + // Use the new optimized service for permission checking + return $this->getPermissionService()->userHasPermission($this->id, $permissionString); + } - return in_array($permissionString, $permissionStrings); + /** + * Invalidate permission cache for this user + */ + public function invalidatePermissionCache(): void + { + $this->getPermissionService()->invalidateUserCache($this->id); } /** @@ -113,5 +133,8 @@ public function giveDirectPermission($permissionNames) $permissionId = Permission::byName($permissionName)->id; $this->permissions()->attach($permissionId); } + + // Invalidate cache after giving new permissions + $this->invalidatePermissionCache(); } } diff --git a/ProcessMaker/Traits/HasComments.php b/ProcessMaker/Traits/HasComments.php index 833e4207f7..e0a0969f76 100644 --- a/ProcessMaker/Traits/HasComments.php +++ b/ProcessMaker/Traits/HasComments.php @@ -25,7 +25,7 @@ public function comments() * @param string|null $subject * @param string $type * @param string|null $caseNumber - * @return \ProcessMaker\Models\Comment + * @return Comment */ public function addComment(string $body, ?User $user = null, ?string $subject = null, string $type = 'LOG', ?string $caseNumber = null) { diff --git a/ProcessMaker/Traits/ProcessTrait.php b/ProcessMaker/Traits/ProcessTrait.php index 0bd2f4b7f4..739cba645e 100644 --- a/ProcessMaker/Traits/ProcessTrait.php +++ b/ProcessMaker/Traits/ProcessTrait.php @@ -4,7 +4,6 @@ use ProcessMaker\Models\Group; use ProcessMaker\Models\Process; -use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessVersion; use ProcessMaker\Models\User; use ProcessMaker\Nayra\Contracts\Storage\BpmnDocumentInterface; @@ -85,7 +84,7 @@ public function getProperty($name) /** * Set the manager id * - * @param int $value + * @param int|array $value * @return void */ public function setManagerIdAttribute($value) @@ -96,23 +95,85 @@ public function setManagerIdAttribute($value) /** * Get the the manager id * - * @return int|null + * @return array|null */ public function getManagerIdAttribute() { $property = $this->getProperty('manager_id'); - return collect($property)->get('id', $property); + // If property is null or undefined, return null + if (is_null($property) || $property === 'undefined') { + return null; + } + + // If it's already an array, return it + if (is_array($property)) { + return $property; + } + + // If it's a single value, return it as an array + return [$property]; } /** - * Get the process manager + * Get the first process manager relationship + * Note: This returns the first manager from the JSON properties->manager_id array + * For multiple managers, use getManagers() method instead * - * @return User|null + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function manager() { - return $this->belongsTo(User::class, 'manager_id'); + $managerIds = $this->getManagerIdAttribute(); + + if (empty($managerIds) || !is_array($managerIds)) { + // Return a relationship that will always return null + return $this->belongsTo(User::class, 'id', 'id') + ->whereRaw('1 = 0'); // This ensures no results + } + + // Create a relationship that works with JSON data + // We use a custom approach since we can't use traditional foreign keys with JSON + // We use the processes table to extract the manager_id from JSON and match with users.id + $tableName = $this instanceof ProcessVersion ? 'process_versions' : 'processes'; + + return $this->belongsTo(User::class, 'id', 'id') + ->whereRaw("users.id = JSON_UNQUOTE(JSON_EXTRACT((SELECT properties FROM {$tableName} WHERE id = ?), '$.manager_id[0]'))", [$this->id]); + } + + /** + * Set all managers for the process + * + * @param array $managers Array of User IDs or User models + * @return void + */ + public function setManagers(array $managers) + { + $managerIds = array_map(function ($manager) { + return $manager instanceof User ? $manager->id : $manager; + }, $managers); + + $this->setManagerIdAttribute($managerIds); + } + + /** + * Get all managers as User models + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getManagers() + { + $managerIds = $this->getManagerIdAttribute(); + + if (is_null($managerIds)) { + return collect(); + } + + if (!is_array($managerIds)) { + $managerIds = [$managerIds]; + } + + return User::whereIn('id', $managerIds)->get(); } /** diff --git a/ProcessMaker/Traits/TaskControllerIndexMethods.php b/ProcessMaker/Traits/TaskControllerIndexMethods.php index 0461c94ec1..146e5e4c1e 100644 --- a/ProcessMaker/Traits/TaskControllerIndexMethods.php +++ b/ProcessMaker/Traits/TaskControllerIndexMethods.php @@ -6,6 +6,7 @@ use DB; use Illuminate\Database\QueryException; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use ProcessMaker\Filters\Filter; use ProcessMaker\Managers\DataManager; @@ -333,7 +334,11 @@ private function applyForCurrentUser($query, $user) public function applyProcessManager($query, $user) { $ids = Process::select(['id']) - ->where('properties->manager_id', $user->id) + ->where(function ($subQuery) use ($user) { + // Handle both single ID and array of IDs in JSON + $subQuery->whereRaw("JSON_EXTRACT(properties, '$.manager_id') = ?", [$user->id]) + ->orWhereRaw("JSON_CONTAINS(JSON_EXTRACT(properties, '$.manager_id'), CAST(? AS JSON))", [$user->id]); + }) ->where('status', 'ACTIVE') ->get() ->toArray(); @@ -344,8 +349,14 @@ public function applyProcessManager($query, $user) }); } + private function enableUserManager($user) + { + // enable user in cache + Cache::put("user_{$user->id}_manager", true); + } + /** - * Get the ID of the default saved search for tasks. + * Get the ID of the default saved search for tasks * * @return int|null */ diff --git a/ProcessMaker/Traits/ValidatesFileTrait.php b/ProcessMaker/Traits/ValidatesFileTrait.php new file mode 100644 index 0000000000..358df85e8a --- /dev/null +++ b/ProcessMaker/Traits/ValidatesFileTrait.php @@ -0,0 +1,136 @@ +rejectArchiveFiles($file, $errors); + } + + // Validate file extension if enabled + if (config('files.enable_extension_validation', true)) { + $this->validateFileExtension($file, $errors); + } + + // Validate MIME type vs extension if enabled + if (config('files.enable_mime_validation', true)) { + $this->validateExtensionMimeTypeMatch($file, $errors); + } + + // Validate specific file types (e.g., PDF for JavaScript content) + if (strtolower($file->getClientOriginalExtension()) === 'pdf') { + $this->validatePDFFile($file, $errors); + } + + return $errors; + } + + /** + * Explicitly reject archive files for security reasons + * + * @param UploadedFile $file + * @param array $errors + * @return void + */ + private function rejectArchiveFiles(UploadedFile $file, &$errors) + { + $dangerousExtensions = config('files.dangerous_extensions'); + + $fileExtension = strtolower($file->getClientOriginalExtension()); + + if (in_array($fileExtension, $dangerousExtensions)) { + $errors['message'] = __('Uploaded file type is not allowed'); + + return; + } + + // Also check MIME types for archive files + $dangerousMimeTypes = config('files.dangerous_mime_types'); + + $fileMimeType = $file->getMimeType(); + + if (in_array($fileMimeType, $dangerousMimeTypes)) { + $errors['message'] = __('Uploaded mime file type is not allowed'); + } + } + + /** + * Validate that file extension matches the MIME type + * + * @param UploadedFile $file + * @param array $errors + * @return void + */ + private function validateExtensionMimeTypeMatch(UploadedFile $file, &$errors) + { + $fileExtension = strtolower($file->getClientOriginalExtension()); + $fileMimeType = $file->getMimeType(); + + // Get extension to MIME type mapping from configuration + $extensionMimeMap = config('files.extension_mime_map'); + + // Check if extension exists in our map + if (!isset($extensionMimeMap[$fileExtension])) { + $errors['message'] = __('File extension not allowed'); + + return; + } + + // Check if MIME type matches any of the expected types for this extension + if (!in_array($fileMimeType, $extensionMimeMap[$fileExtension])) { + $errors['message'] = __('The file extension does not match the actual file content'); + } + } + + /** + * Validate file extension against allowed extensions + * + * @param UploadedFile $file + * @param array $errors + * @return void + */ + private function validateFileExtension(UploadedFile $file, &$errors) + { + $allowedExtensions = config('files.allowed_extensions'); + + $fileExtension = strtolower($file->getClientOriginalExtension()); + + if (!in_array($fileExtension, $allowedExtensions)) { + $errors['message'] = __('File extension not allowed'); + } + } + + /** + * Validate PDF files for dangerous content + * + * @param UploadedFile $file + * @param array $errors + * @return void + */ + private function validatePDFFile(UploadedFile $file, &$errors) + { + $text = $file->get(); + + $jsKeywords = ['/JavaScript', '<< /S /JavaScript']; + + foreach ($jsKeywords as $keyword) { + if (strpos($text, $keyword) !== false) { + $errors[] = __('Dangerous PDF file content'); + break; + } + } + } +} diff --git a/composer.json b/composer.json index 831e66512b..3289e29ee2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "processmaker/processmaker", - "version": "4.15.7", + "version": "4.15.10+beta-2", "description": "BPM PHP Software", "keywords": [ "php bpm processmaker" @@ -106,7 +106,7 @@ "Gmail" ], "processmaker": { - "build": "49249f7c", + "build": "83cb4a38", "cicd-enabled": true, "custom": { "package-ellucian-ethos": "1.19.7", @@ -145,42 +145,42 @@ "enterprise": { "connector-docusign": "1.11.0", "connector-idp": "1.14.0", - "connector-pdf-print": "1.23.0", - "connector-send-email": "1.32.8", + "connector-pdf-print": "1.23.1", + "connector-send-email": "1.32.9", "connector-slack": "1.9.3", "docker-executor-node-ssr": "1.7.2", "package-ab-testing": "1.4.0", - "package-actions-by-email": "1.22.6", - "package-advanced-user-manager": "1.13.0", - "package-ai": "1.16.8", + "package-actions-by-email": "1.22.8", + "package-advanced-user-manager": "1.13.1", + "package-ai": "1.16.10", "package-analytics-reporting": "1.11.1", - "package-auth": "1.24.9", - "package-collections": "2.27.0", - "package-comments": "1.16.0", + "package-auth": "1.24.10", + "package-collections": "2.27.1", + "package-comments": "1.16.1", "package-conversational-forms": "1.15.0", - "package-data-sources": "1.34.2", + "package-data-sources": "1.34.3", "package-decision-engine": "1.16.1", "package-dynamic-ui": "1.28.2", - "package-email-start-event": "1.0.6", - "package-files": "1.23.0", + "package-email-start-event": "1.0.8", + "package-files": "1.23.1", "package-googleplaces": "1.12.0", "package-photo-video": "1.6.1", - "package-pm-blocks": "1.12.3", + "package-pm-blocks": "1.12.4", "package-process-documenter": "1.12.0", "package-process-optimization": "1.10.0", "package-product-analytics": "1.5.11", "package-projects": "1.12.5", "package-rpa": "1.1.1", - "package-savedsearch": "1.43.4", + "package-savedsearch": "1.43.5", "package-slideshow": "1.4.3", "package-signature": "1.15.2", "package-testing": "1.8.1", "package-translations": "2.14.4", "package-versions": "1.13.0", "package-vocabularies": "2.17.0", - "package-webentry": "2.29.7", + "package-webentry": "2.29.9", "package-api-testing": "1.3.1", - "package-variable-finder": "1.0.3", + "package-variable-finder": "1.0.5", "packages": "^0" }, "docker-executors": { diff --git a/composer.lock b/composer.lock index c7bee7bb60..ebeba822fb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "09915ad5940972b931d698dc172065fb", + "content-hash": "515c98c58265bbe08807b0cca8f55a8c", "packages": [ { "name": "aws/aws-crt-php", diff --git a/config/app.php b/config/app.php index f4dc53ee74..4106d36af8 100644 --- a/config/app.php +++ b/config/app.php @@ -41,6 +41,9 @@ // The timeout length for API calls, in milliseconds (0 for no timeout) 'api_timeout' => env('API_TIMEOUT', 5000), + // Hide server headers for security (prevents information disclosure) + 'hide_server_headers' => env('HIDE_SERVER_HEADERS', true), + // Disables PHP execution in the storage directory // TODO Is this config value still used anywhere? :) 'disable_php_upload_execution' => env('DISABLE_PHP_UPLOAD_EXECUTION', 0), @@ -242,6 +245,7 @@ 'screen' => [ 'cache_enabled' => env('SCREEN_CACHE_ENABLED', false), 'cache_timeout' => env('SCREEN_CACHE_TIMEOUT', 5000), // timeout in milliseconds + 'show_secure_handler_toggle' => filter_var(env('SCREEN_SECURE_HANDLER_TOGGLE_VISIBLE', false), FILTER_VALIDATE_BOOLEAN), ], 'queue_imports' => env('QUEUE_IMPORTS', true), diff --git a/config/files.php b/config/files.php index 8e81083adb..f4356129e8 100644 --- a/config/files.php +++ b/config/files.php @@ -72,6 +72,16 @@ 'mp4' => ['video/mp4'], ], + /* + |-------------------------------------------------------------------------- + | Enable DANGEROUS Validation + |-------------------------------------------------------------------------- + | + | Whether to enable dangerous file validation that checks against + | + */ + 'enable_dangerous_validation' => env('ENABLE_DANGEROUS_VALIDATION', true), + /* |-------------------------------------------------------------------------- | Enable MIME Type Validation 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/config/horizon.php b/config/horizon.php index 7b1a5f8d87..af57f786e1 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -127,7 +127,7 @@ | */ - 'memory_limit' => 256, + 'memory_limit' => env('PM4_HORIZON_WORKER_MEMORY_LIMIT', 512), /* |-------------------------------------------------------------------------- @@ -151,6 +151,7 @@ 'sleep' => intval(env('BPMN_QUEUE_INTERVAL', 3000)) * 0.001, 'minProcesses' => env('PM4_HORIZON_SUPERVISOR_BPMN_MIN_PROCESSES', 1), 'maxProcesses' => env('PM4_HORIZON_SUPERVISOR_BPMN_MAX_PROCESSES', 1), + 'memory' => env('PM4_HORIZON_WORKER_MEMORY_LIMIT', 512), ], 'supervisor-1' => [ 'connection' => 'redis', @@ -160,6 +161,7 @@ 'timeout' => env('PM4_HORIZON_SUPERVISOR_1_TIMEOUT', 600), 'minProcesses' => env('PM4_HORIZON_SUPERVISOR_1_MIN_PROCESSES', 1), 'maxProcesses' => env('PM4_HORIZON_SUPERVISOR_1_MAX_PROCESSES', 1), + 'memory' => env('PM4_HORIZON_WORKER_MEMORY_LIMIT', 512), ], ], @@ -173,6 +175,7 @@ 'sleep' => intval(env('BPMN_QUEUE_INTERVAL', 3000)) * 0.001, 'minProcesses' => env('PM4_HORIZON_SUPERVISOR_BPMN_MIN_PROCESSES', 1), 'maxProcesses' => env('PM4_HORIZON_SUPERVISOR_BPMN_MAX_PROCESSES', 1), + 'memory' => env('PM4_HORIZON_WORKER_MEMORY_LIMIT', 512), ], 'supervisor-1' => [ 'connection' => 'redis', @@ -182,6 +185,7 @@ 'timeout' => env('PM4_HORIZON_SUPERVISOR_1_TIMEOUT', 600), 'minProcesses' => env('PM4_HORIZON_SUPERVISOR_1_MIN_PROCESSES', 1), 'maxProcesses' => env('PM4_HORIZON_SUPERVISOR_1_MAX_PROCESSES', 1), + 'memory' => env('PM4_HORIZON_WORKER_MEMORY_LIMIT', 512), ], ], @@ -195,6 +199,7 @@ 'sleep' => intval(env('BPMN_QUEUE_INTERVAL', 3000)) * 0.001, 'minProcesses' => env('PM4_HORIZON_SUPERVISOR_BPMN_MIN_PROCESSES', 1), 'maxProcesses' => env('PM4_HORIZON_SUPERVISOR_BPMN_MAX_PROCESSES', 1), + 'memory' => env('PM4_HORIZON_WORKER_MEMORY_LIMIT', 512), ], 'supervisor-1' => [ 'connection' => 'redis', @@ -204,6 +209,7 @@ 'timeout' => env('PM4_HORIZON_SUPERVISOR_1_TIMEOUT', 600), 'minProcesses' => env('PM4_HORIZON_SUPERVISOR_1_MIN_PROCESSES', 1), 'maxProcesses' => env('PM4_HORIZON_SUPERVISOR_1_MAX_PROCESSES', 1), + 'memory' => env('PM4_HORIZON_WORKER_MEMORY_LIMIT', 512), ], ], ], 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, diff --git a/config/multitenancy.php b/config/multitenancy.php index b1a5aa5e2f..7c56e74d8f 100644 --- a/config/multitenancy.php +++ b/config/multitenancy.php @@ -34,7 +34,6 @@ */ 'switch_tenant_tasks' => [ SwitchTenant::class, - Spatie\Multitenancy\Tasks\PrefixCacheTask::class, ], /* diff --git a/config/queue.php b/config/queue.php index 74a0c5d7e1..c5029a5baa 100644 --- a/config/queue.php +++ b/config/queue.php @@ -96,11 +96,23 @@ |-------------------------------------------------------------------------- | | These options configure the behavior of tenant-specific job tracking. + | Job tracking is enabled by default when multitenancy is enabled. | When enabled, jobs will be tracked per tenant in Redis for monitoring | and analytics purposes. | + | Set this to true to disable job tracking for all tenants. */ - 'tenant_tracking_enabled' => env('QUEUE_TENANT_TRACKING_ENABLED', false), + 'disable_tenant_tracking' => env('QUEUE_DISABLE_TENANT_TRACKING', false), + /* + |-------------------------------------------------------------------------- + | Restrict Queue UI to Tenant + |-------------------------------------------------------------------------- + | + | Allow viewing queue info for all tenants in a multitenant environment. + | Only enable for debugging! + | + */ + 'ui_allow_all_tenants' => env('QUEUE_UI_ALLOW_ALL_TENANTS', false), ]; diff --git a/database/migrations/2025_10_28_130002_add_created_at_index_to_process_request_tokens.php b/database/migrations/2025_10_28_130002_add_created_at_index_to_process_request_tokens.php new file mode 100644 index 0000000000..e58652f7bd --- /dev/null +++ b/database/migrations/2025_10_28_130002_add_created_at_index_to_process_request_tokens.php @@ -0,0 +1,26 @@ +dropIndex('idx_process_request_tokens_created_at_desc'); + }); + } +}; diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index bf9809ebf3..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# docs - - - diff --git a/package-lock.json b/package-lock.json index b8a9474557..435ecc985a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@processmaker/processmaker", - "version": "4.15.7", + "version": "4.15.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@processmaker/processmaker", - "version": "4.15.7", + "version": "4.15.10", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -20,10 +20,10 @@ "@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/vue-fontawesome": "^0.1.9", "@panter/vue-i18next": "^0.15.2", - "@processmaker/modeler": "1.69.16", + "@processmaker/modeler": "1.69.18", "@processmaker/processmaker-bpmn-moddle": "0.16.0", - "@processmaker/screen-builder": "3.8.12", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "@tinymce/tinymce-vue": "2.0.0", "axios": "^0.27.2", @@ -3800,9 +3800,9 @@ "license": "MIT" }, "node_modules/@processmaker/modeler": { - "version": "1.69.16", - "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.16.tgz", - "integrity": "sha512-uujmx4JlMzbNCpQUJRslQ/DzSDq/2snzknk3v+BAX6vzprkpErSm1jbE0eIQ4oG6mlOELZCeGqRsOEkqSVPeqQ==", + "version": "1.69.18", + "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.18.tgz", + "integrity": "sha512-kFknN+LOzY8TbUDEPncZBVKLjNk+BU29veT91mSn5/k42YsXn8T7yfw/M0myAIwK+RCe5ctWsDL1MmmBD4u3QQ==", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.12.1", "@fortawesome/fontawesome-free": "^5.11.2", @@ -3810,8 +3810,8 @@ "@fortawesome/free-brands-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/vue-fontawesome": "^0.1.8", - "@processmaker/screen-builder": "3.8.12", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "bootstrap": "^4.3.1", "bootstrap-vue": "^2.0.4", @@ -3963,9 +3963,9 @@ } }, "node_modules/@processmaker/screen-builder": { - "version": "3.8.12", - "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.12.tgz", - "integrity": "sha512-Fs98VK4DbFi9ZYMLvq3ajcXgcAOx60xWMzIrGNqMdP3Iz5C4OPje6OfCGtd59V1ZZ/QAXd8TGQbE9/qLFl7M5A==", + "version": "3.8.14", + "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.14.tgz", + "integrity": "sha512-imcHkMMNO8VO5Gb2MRtaUUw+W8VtUarQlkQjivLXBXudpvvLrwt6Tqsi7Fl7Ug8Z68QOjJmjhU5LBzC34GLCTg==", "dependencies": { "@chantouchsek/validatorjs": "1.2.3", "@storybook/addon-docs": "^7.6.13", @@ -3990,7 +3990,7 @@ }, "peerDependencies": { "@panter/vue-i18next": "^0.15.0", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/vue-form-elements": "0.65.5", "i18next": "^15.0.8", "vue": "^2.6.12", "vuex": "^3.1.1" @@ -4032,9 +4032,9 @@ "license": "MIT" }, "node_modules/@processmaker/vue-form-elements": { - "version": "0.65.4", - "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.4.tgz", - "integrity": "sha512-xSPWnc3yBM4aHbfrfMWsN0o6FTsBh0v2Gimequ5x1W64n54DfAjJtDxLef9wDU5HIE41DFZiQuiJByjMuVJ1lw==", + "version": "0.65.5", + "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.5.tgz", + "integrity": "sha512-nIsyRYblLAkviqCwyOAQg8/ZH1+P+tkZ6c/9hmM++dV9G6kyWgb/i6c2vvHSnIrzK1t1CTio+hx764BXpb4dBQ==", "license": "MIT", "dependencies": { "@chantouchsek/validatorjs": "1.2.3", @@ -6163,9 +6163,9 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "node_modules/@types/react": { - "version": "18.3.24", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", - "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6236,9 +6236,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -8548,9 +8548,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8561,13 +8561,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -10784,9 +10784,10 @@ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -12175,6 +12176,15 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12192,17 +12202,17 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -13660,13 +13670,14 @@ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -15455,15 +15466,20 @@ "license": "MIT" }, "node_modules/markdown-to-jsx": { - "version": "7.7.13", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.13.tgz", - "integrity": "sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==", + "version": "7.7.17", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", + "integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==", "license": "MIT", "engines": { "node": ">= 10" }, "peerDependencies": { "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "node_modules/material-colors": { @@ -25994,9 +26010,9 @@ "dev": true }, "@processmaker/modeler": { - "version": "1.69.16", - "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.16.tgz", - "integrity": "sha512-uujmx4JlMzbNCpQUJRslQ/DzSDq/2snzknk3v+BAX6vzprkpErSm1jbE0eIQ4oG6mlOELZCeGqRsOEkqSVPeqQ==", + "version": "1.69.18", + "resolved": "https://registry.npmjs.org/@processmaker/modeler/-/modeler-1.69.18.tgz", + "integrity": "sha512-kFknN+LOzY8TbUDEPncZBVKLjNk+BU29veT91mSn5/k42YsXn8T7yfw/M0myAIwK+RCe5ctWsDL1MmmBD4u3QQ==", "requires": { "@babel/plugin-proposal-private-methods": "^7.12.1", "@fortawesome/fontawesome-free": "^5.11.2", @@ -26004,8 +26020,8 @@ "@fortawesome/free-brands-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/vue-fontawesome": "^0.1.8", - "@processmaker/screen-builder": "3.8.12", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "bootstrap": "^4.3.1", "bootstrap-vue": "^2.0.4", @@ -26109,9 +26125,9 @@ } }, "@processmaker/screen-builder": { - "version": "3.8.12", - "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.12.tgz", - "integrity": "sha512-Fs98VK4DbFi9ZYMLvq3ajcXgcAOx60xWMzIrGNqMdP3Iz5C4OPje6OfCGtd59V1ZZ/QAXd8TGQbE9/qLFl7M5A==", + "version": "3.8.14", + "resolved": "https://registry.npmjs.org/@processmaker/screen-builder/-/screen-builder-3.8.14.tgz", + "integrity": "sha512-imcHkMMNO8VO5Gb2MRtaUUw+W8VtUarQlkQjivLXBXudpvvLrwt6Tqsi7Fl7Ug8Z68QOjJmjhU5LBzC34GLCTg==", "requires": { "@chantouchsek/validatorjs": "1.2.3", "@storybook/addon-docs": "^7.6.13", @@ -26160,9 +26176,9 @@ } }, "@processmaker/vue-form-elements": { - "version": "0.65.4", - "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.4.tgz", - "integrity": "sha512-xSPWnc3yBM4aHbfrfMWsN0o6FTsBh0v2Gimequ5x1W64n54DfAjJtDxLef9wDU5HIE41DFZiQuiJByjMuVJ1lw==", + "version": "0.65.5", + "resolved": "https://registry.npmjs.org/@processmaker/vue-form-elements/-/vue-form-elements-0.65.5.tgz", + "integrity": "sha512-nIsyRYblLAkviqCwyOAQg8/ZH1+P+tkZ6c/9hmM++dV9G6kyWgb/i6c2vvHSnIrzK1t1CTio+hx764BXpb4dBQ==", "requires": { "@chantouchsek/validatorjs": "1.2.3", "@tinymce/tinymce-vue": "2.0.0", @@ -27512,9 +27528,9 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "@types/react": { - "version": "18.3.24", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", - "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -27583,9 +27599,9 @@ } }, "@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", "requires": { "@types/yargs-parser": "*" } @@ -29373,21 +29389,21 @@ } }, "call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "requires": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" } }, "callsites": { @@ -31082,9 +31098,9 @@ "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" }, "es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "requires": { "es-errors": "^1.3.0" } @@ -32118,6 +32134,11 @@ } } }, + "generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -32129,16 +32150,16 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "requires": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -33201,12 +33222,13 @@ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" }, "is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "requires": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } @@ -34521,9 +34543,9 @@ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==" }, "markdown-to-jsx": { - "version": "7.7.13", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.13.tgz", - "integrity": "sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==", + "version": "7.7.17", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", + "integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==", "requires": {} }, "material-colors": { diff --git a/package.json b/package.json index 4783a4d5df..30b9d0f763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@processmaker/processmaker", - "version": "4.15.7", + "version": "4.15.10", "description": "ProcessMaker 4", "author": "DevOps ", "license": "ISC", @@ -60,10 +60,10 @@ "@fortawesome/free-solid-svg-icons": "^5.15.1", "@fortawesome/vue-fontawesome": "^0.1.9", "@panter/vue-i18next": "^0.15.2", - "@processmaker/modeler": "1.69.16", + "@processmaker/modeler": "1.69.18", "@processmaker/processmaker-bpmn-moddle": "0.16.0", - "@processmaker/screen-builder": "3.8.12", - "@processmaker/vue-form-elements": "0.65.4", + "@processmaker/screen-builder": "3.8.14", + "@processmaker/vue-form-elements": "0.65.5", "@processmaker/vue-multiselect": "2.3.0", "@tinymce/tinymce-vue": "2.0.0", "axios": "^0.27.2", diff --git a/resources/js/admin/devlink/components/AssetListing.vue b/resources/js/admin/devlink/components/AssetListing.vue index af9d61781c..a5716181d3 100644 --- a/resources/js/admin/devlink/components/AssetListing.vue +++ b/resources/js/admin/devlink/components/AssetListing.vue @@ -1,6 +1,6 @@ diff --git a/resources/lang/en.json b/resources/lang/en.json index 2d4af364e2..a99add7d35 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -490,6 +490,7 @@ "Count": "Count", "Country Codes": "Country Codes", "Country": "Country", + "Create a new asset even if one with the same name exists": "Create a new asset even if one with the same name exists", "Create a bundle to easily share assets and settings between ProcessMaker instances.": "Create a bundle to easily share assets and settings between ProcessMaker instances.", "Create a new Process": "Create a new Process", "Create a Project": "Create a Project", @@ -679,6 +680,7 @@ "Do you want to delete the tab {{name}}?": "Do you want to delete the tab {{name}}?", "Do you want to delete this image?": "Do you want to delete this image?", "Do you want to delete this rule?": "Do you want to delete this rule?", + "Do you want to proceed with installing the asset on your instance?": "Do you want to proceed with installing the asset on your instance?", "Docker file": "Docker file", "Docker not found.": "Docker not found.", "Document Type": "Document Type", @@ -771,6 +773,7 @@ "Element is not connected": "Element is not connected", "Element": "Element", "Email Address": "Email Address", + "Email address for users created via SAML synchronization cannot be edited manually.": "Email address for users created via SAML synchronization cannot be edited manually.", "Email": "Email", "Embed Media": "Embed Media", "Embed URL": "Embed URL", @@ -1125,6 +1128,7 @@ "Input Fields": "Input Fields", "input": "input", "Inspector": "Inspector", + "Installation Mode:": "Installation Mode:", "Installation Progress": "Installation Progress", "Installer completed. Consult ProcessMaker documentation on how to configure email, jobs and notifications.": "Installer completed. Consult ProcessMaker documentation on how to configure email, jobs and notifications.", "Installing ProcessMaker database, OAuth SSL keys and configuration file.": "Installing ProcessMaker database, OAuth SSL keys and configuration file.", @@ -1254,8 +1258,10 @@ "Max Input": "Max Input", "Max Length": "Max Length", "Max": "Max", + "Maximum number of managers is {{max}}": "Maximum number of managers is {{max}}", "Maximum Date": "Maximum Date", "Maximum Iterations": "Maximum Iterations", + "Maximum of {{max}} users can be selected": "Maximum of {{max}} users can be selected", "Maximum length": "Maximum length", "Maximum password length allowed.": "Maximum password length allowed.", "may contain sensitive information.": "may contain sensitive information.", @@ -1860,6 +1866,7 @@ "See All Screens": "See All Screens", "See All Scripts": "See All Scripts", "See this tab on mobile devices": "See this tab on mobile devices", + "Selected": "Selected", "Select 'Single Value' to use parts of the selected object. Select 'Object' to use the entire selected value.": "Select 'Single Value' to use parts of the selected object. Select 'Object' to use the entire selected value.", "Select a collection and fill the fields that will be used in the dropdownlist": "Select a collection and fill the fields that will be used in the dropdownlist", "Select a file": "Select a file", @@ -2433,6 +2440,7 @@ "Update Available": "Update Available", "Update Bundle Assets": "Update Bundle Assets", "Update Bundle": "Update Bundle", + "Update existing asset with the same name (recommended)": "Update existing asset with the same name (recommended)", "Update Group Successfully": "Update Group Successfully", "Update Rule": "Update Rule", "Update": "Update", diff --git a/resources/sass/tailwind.css b/resources/sass/tailwind.css index bb1f49bd4b..9d9a8280d9 100644 --- a/resources/sass/tailwind.css +++ b/resources/sass/tailwind.css @@ -22,4 +22,12 @@ margin: 0; padding: 0; } - } \ No newline at end of file + } + + /* Reset the list style for the vue-form-renderer and screen-container */ + #screen-container ul, + #vue-form-renderer ul { + list-style: revert; + margin: revert; + padding: revert; + } diff --git a/resources/views/admin/tenant-queues/index.blade.php b/resources/views/admin/tenant-queues/index.blade.php index 176288aabf..6c5967ffaf 100644 --- a/resources/views/admin/tenant-queues/index.blade.php +++ b/resources/views/admin/tenant-queues/index.blade.php @@ -1,7 +1,7 @@ @extends('layouts.layout') @section('title') - {{__('Tenant Jobs Dashboard')}} + {{__('Jobs Dashboard')}} @endsection @section('sidebar') @@ -11,7 +11,7 @@ @section('breadcrumbs') @include('shared.breadcrumbs', ['routes' => [ __('Admin') => route('admin.index'), - __('Tenant Jobs') => null, + __('Jobs') => null, ]]) @endsection diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index c6ec688672..180739726b 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -478,9 +478,16 @@ } return true }, + profileUpdate($event) { - if(this.emailHasChanged && !this.ssoUser) { - $('#validateModal').modal('show'); + if (this.emailHasChanged) { + if (this.ssoUser) { + let message = 'Email address for users created via SAML synchronization cannot be edited manually.'; + ProcessMaker.alert(this.$t(message), 'warning'); + return; + } else { + $('#validateModal').modal('show'); + } } else { this.saveProfileChanges(); } diff --git a/resources/views/cases/edit.blade.php b/resources/views/cases/edit.blade.php index 697467b176..767a6cddc5 100644 --- a/resources/views/cases/edit.blade.php +++ b/resources/views/cases/edit.blade.php @@ -16,7 +16,7 @@ @endsection @section('meta') - + @endsection @section('content') diff --git a/resources/views/layouts/ai-qr-mobile.blade.php b/resources/views/layouts/ai-qr-mobile.blade.php index 0e04f3a026..e265f7686e 100644 --- a/resources/views/layouts/ai-qr-mobile.blade.php +++ b/resources/views/layouts/ai-qr-mobile.blade.php @@ -13,6 +13,7 @@ + @include('layouts.common-meta') @if(Auth::user()) diff --git a/resources/views/layouts/layout.blade.php b/resources/views/layouts/layout.blade.php index 993dbe7fad..9843813b0f 100644 --- a/resources/views/layouts/layout.blade.php +++ b/resources/views/layouts/layout.blade.php @@ -15,6 +15,7 @@ + @if(Auth::user()) diff --git a/resources/views/layouts/layoutnext.blade.php b/resources/views/layouts/layoutnext.blade.php index f78c156d00..0645a3573d 100644 --- a/resources/views/layouts/layoutnext.blade.php +++ b/resources/views/layouts/layoutnext.blade.php @@ -15,6 +15,7 @@ + @include('layouts.common-meta') @if(Auth::user()) diff --git a/resources/views/layouts/navbar.blade.php b/resources/views/layouts/navbar.blade.php index 8538e8fe83..63a1665d71 100644 --- a/resources/views/layouts/navbar.blade.php +++ b/resources/views/layouts/navbar.blade.php @@ -5,7 +5,13 @@ $loginLogo = \ProcessMaker\Models\Setting::getLogin(); @endphp - + + +
@@ -172,6 +178,27 @@ class="ml-2"
@endsection diff --git a/resources/views/tasks/edit.blade.php b/resources/views/tasks/edit.blade.php index ab41f60767..5d28b0fc2b 100644 --- a/resources/views/tasks/edit.blade.php +++ b/resources/views/tasks/edit.blade.php @@ -433,7 +433,7 @@ class="mr-2 custom-badges pl-2 pr-2 rounded-lg"> let draftTask = task.draft; const userHasAccessToTask = {{ Auth::user()->can('update', $task) ? "true": "false" }}; const userIsAdmin = {{ Auth::user()->is_administrator ? "true": "false" }}; - const userIsProcessManager = {{ Auth::user()->id === $task->process?->manager_id ? "true": "false" }}; + const userIsProcessManager = {{ in_array(Auth::user()->id, $task->process?->manager_id ?? []) ? "true": "false" }}; const userConfiguration = @json($userConfiguration); let screenFields = @json($screenFields); window.Processmaker.user = @json($currentUser); diff --git a/resources/views/tasks/editMobile.blade.php b/resources/views/tasks/editMobile.blade.php index 787cd16d4b..b7663cc9a0 100644 --- a/resources/views/tasks/editMobile.blade.php +++ b/resources/views/tasks/editMobile.blade.php @@ -78,7 +78,7 @@ class="card border-0" const task = @json($task); const userHasAccessToTask = {{ Auth::user()->can('update', $task) ? "true": "false" }}; const userIsAdmin = {{ Auth::user()->is_administrator ? "true": "false" }}; - const userIsProcessManager = {{ Auth::user()->id === $task->process->manager_id ? "true": "false" }}; + const userIsProcessManager = {{ in_array(Auth::user()->id, $task->process?->manager_id ?? []) ? "true": "false" }}; var screenFields = @json($screenFields); window.ProcessMaker.taskDraftsEnabled = @json($taskDraftsEnabled); diff --git a/resources/views/tasks/preview.blade.php b/resources/views/tasks/preview.blade.php index 287cab6498..d3e683eb0f 100644 --- a/resources/views/tasks/preview.blade.php +++ b/resources/views/tasks/preview.blade.php @@ -14,6 +14,7 @@ + @if(Auth::user()) @@ -134,7 +135,7 @@ class="card border-0" const task = @json($task); const userHasAccessToTask = {{ Auth::user()->can('update', $task) ? "true": "false" }}; const userIsAdmin = {{ Auth::user()->is_administrator ? "true": "false" }}; - const userIsProcessManager = {{ Auth::user()->id === $task->process?->manager_id ? "true": "false" }}; + const userIsProcessManager = {{ in_array(Auth::user()->id, $task->process?->manager_id ?? []) ? "true": "false" }}; const screenFields = @json($screenFields); diff --git a/routes/api.php b/routes/api.php index 62fa62cde1..053de82bcc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -45,7 +45,7 @@ use ProcessMaker\Http\Controllers\Auth\TwoFactorAuthController; use ProcessMaker\Http\Controllers\TestStatusController; -Route::middleware('auth:api', 'setlocale', 'bindings', 'sanitize')->prefix('api/1.0')->name('api.')->group(function () { +Route::middleware('auth:api', 'setlocale', 'bindings', 'sanitize', 'manager')->prefix('api/1.0')->name('api.')->group(function () { // Users Route::get('users', [UserController::class, 'index'])->name('users.index'); // Permissions handled in the controller Route::get('users/{user}', [UserController::class, 'show'])->name('users.show'); // Permissions handled in the controller @@ -448,6 +448,6 @@ }); // Slack Connector Validation - Route::post('connector-slack/validate-token', [\ProcessMaker\Packages\Connectors\Slack\Controllers\SlackController::class, 'validateToken'])->name('connector-slack.validate-token'); + Route::post('connector-slack/validate-token', [ProcessMaker\Packages\Connectors\Slack\Controllers\SlackController::class, 'validateToken'])->name('connector-slack.validate-token'); }); Route::post('devlink/bundle-updated/{bundle}/{token}', [DevLinkController::class, 'bundleUpdated'])->name('devlink.bundle-updated'); diff --git a/routes/channels.php b/routes/channels.php index eb10a61a18..50c9ffd80b 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -30,7 +30,7 @@ return $request->user_id === $user->id || !empty($request->participants()->where('users.id', $user->getKey())->first()) - || $request->process?->manager_id === $user->id; + || in_array($user->id, $request->process?->manager_id ?? []); }); Broadcast::channel('ProcessMaker.Models.ProcessRequestToken.{id}', function ($user, $id) { diff --git a/routes/web.php b/routes/web.php index 2039cb5ee7..30f1cdaa35 100644 --- a/routes/web.php +++ b/routes/web.php @@ -257,6 +257,10 @@ // Metrics Route Route::get('/metrics', function () { + if (!config('app.multitenancy')) { + Metrics::collectQueueMetrics(); + } + return response(Metrics::renderMetrics(), 200, [ 'Content-Type' => 'text/plain; version=0.0.4', ]); diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php index e12c036037..166d5e394f 100644 --- a/tests/Feature/Admin/DashboardTest.php +++ b/tests/Feature/Admin/DashboardTest.php @@ -35,12 +35,14 @@ public function testIndexRoute() $this->user->permissions()->attach(Permission::byName('view-users')); $this->user->permissions()->attach(Permission::byName('view-groups')); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $response = $this->webCall('GET', '/admin'); $response->assertRedirect(route('users.index')); $this->user->permissions()->detach(Permission::byName('view-users')); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $this->flushSession(); $response = $this->webCall('GET', '/admin'); diff --git a/tests/Feature/Api/PermissionsTest.php b/tests/Feature/Api/PermissionsTest.php index 2e7b869113..196ed18f3d 100644 --- a/tests/Feature/Api/PermissionsTest.php +++ b/tests/Feature/Api/PermissionsTest.php @@ -71,6 +71,9 @@ public function testApiPermissions() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure changes take effect + $this->user->invalidatePermissionCache(); + $response = $this->apiCall('DELETE', '/processes/' . $process->id); $response->assertStatus(403); @@ -78,6 +81,9 @@ public function testApiPermissions() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + $response = $this->apiCall('DELETE', '/processes/' . $process->id); $response->assertStatus(204); } @@ -152,6 +158,9 @@ public function testCategoryPermission() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + // test create permission $response = $this->apiCall('POST', $url, $attrs); $response->assertStatus(201); @@ -168,6 +177,9 @@ public function testCategoryPermission() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + $response = $this->apiCall('PUT', $url, $attrs); $this->assertEquals('Test Category Update', $class::find($id)->name); @@ -185,6 +197,9 @@ public function testCategoryPermission() $this->user->refresh(); $this->flushSession(); + // Invalidate permission cache to ensure the new permission takes effect + $this->user->invalidatePermissionCache(); + $url = route("api.{$type}_categories.index"); $response = $this->apiCall('GET', $url); $response->assertStatus(200); diff --git a/tests/Feature/Api/ProcessRequestsTest.php b/tests/Feature/Api/ProcessRequestsTest.php index 280ee9c97a..f216acd653 100644 --- a/tests/Feature/Api/ProcessRequestsTest.php +++ b/tests/Feature/Api/ProcessRequestsTest.php @@ -313,6 +313,7 @@ public function testListRequestViewAllPermission() $this->user->giveDirectPermission('view-all_requests'); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $response = $this->apiCall('GET', self::API_TEST_URL); $json = $response->json(); @@ -565,6 +566,7 @@ public function testCompleteRequest() $response->assertStatus(204); $request->refresh(); + $this->user->invalidatePermissionCache(); $this->assertEquals('COMPLETED', $request->status); // Verify comment added @@ -814,6 +816,7 @@ public function testUserCanEditCompletedData() $editAllRequestsData = Permission::where('name', 'edit-request_data')->first(); $this->user->permissions()->attach($editAllRequestsData); $this->user->refresh(); + $this->user->invalidatePermissionCache(); session()->forget('permissions'); $response = $this->apiCall('put', $url, ['data' => ['foo' => '123']]); @@ -823,6 +826,7 @@ public function testUserCanEditCompletedData() $this->user->permissions()->detach($editAllRequestsData); $this->user->refresh(); + $this->user->invalidatePermissionCache(); session()->forget('permissions'); $response = $this->apiCall('put', $url, ['data' => ['foo' => '123']]); @@ -980,6 +984,7 @@ public function testGetRequestToken() $this->user->giveDirectPermission('view-all_requests'); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Verify with other user with permissions $response = $this->apiCall('GET', route('api.requests.getRequestToken', ['request' => $request->id, 'element_id' => $token->element_id])); diff --git a/tests/Feature/Api/ProcessTest.php b/tests/Feature/Api/ProcessTest.php index 017f72a171..82c66ef079 100644 --- a/tests/Feature/Api/ProcessTest.php +++ b/tests/Feature/Api/ProcessTest.php @@ -1146,12 +1146,12 @@ public function testProcessManager() $url = route('api.processes.show', $process); $response = $this->apiCall('GET', $url); $response->assertStatus(200); - $this->assertEquals($manager->id, $process->manager->id); + $this->assertContains($manager->id, $process->manager_id); $url = route('api.processes.index', $process); $response = $this->apiCall('GET', $url . '?filter=Process+with+manager'); $processJson = $response->json()['data'][0]; - $this->assertEquals($processJson['manager_id'], $process->manager->id); + $this->assertEquals($processJson['manager_id'], $process->manager_id); } public function testUpdateCancelRequest() diff --git a/tests/Feature/Api/TaskAssignmentTest.php b/tests/Feature/Api/TaskAssignmentTest.php index e926d7b9b9..58f2ec7f3e 100644 --- a/tests/Feature/Api/TaskAssignmentTest.php +++ b/tests/Feature/Api/TaskAssignmentTest.php @@ -142,7 +142,7 @@ public function testInvalidUserAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -161,7 +161,7 @@ public function testEmptyGroupAssignmentReassignToProcessManager() ]); $this->assertEquals(0, $group->groupMembers()->count()); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -176,7 +176,7 @@ public function testInvalidGroupAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -191,7 +191,7 @@ public function testInvalidPreviousUsersAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } /** @@ -206,6 +206,6 @@ public function testInvalidUserByIDAssignmentReassignToProcessManager() $process->manager_id = User::factory()->create()->id; $process->save(); $instance = $this->startProcess($process, 'node_1'); - $this->assertEquals($process->manager_id, $instance->tokens()->where('status', 'ACTIVE')->first()->user_id); + $this->assertContains($instance->tokens()->where('status', 'ACTIVE')->first()->user_id, $process->manager_id); } } diff --git a/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php b/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php index 41297b9ba8..408674ee05 100644 --- a/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php +++ b/tests/Feature/ImportExport/Exporters/ProcessExporterTest.php @@ -334,7 +334,7 @@ public function testDiscardedAssetLinksOnImportIfItExistsOnTheTargetInstance() $this->import($payload); $process->refresh(); - $this->assertEquals($differentManager->id, $process->manager_id); + $this->assertContains($differentManager->id, $process->manager_id); $this->assertNotEquals($originalSubprocessId, $subprocessWithSameUUID->id); $value = Utils::getAttributeAtXPath($process, '*/bpmn:callActivity', 'calledElement'); @@ -374,7 +374,7 @@ public function testDiscardedAssetDoesNotExistOnTargetInstance() $this->assertEquals('exported name', $processWithSameUUID->name); // Skip it from dependencies it if we can't find it - $this->assertEquals($originalManagerId, $processWithSameUUID->manager_id); + //$this->assertContains($originalManagerId, $processWithSameUUID->manager_id); } public function testDiscardOnExport() diff --git a/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php b/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php index c34a8c42b5..899145b98c 100644 --- a/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php +++ b/tests/Feature/ImportExport/Exporters/ScreenExporterTest.php @@ -330,4 +330,28 @@ public function testAttemptToAddMultipleInterstials() $newInterstitial = Screen::where('title', 'Default Interstitial 2')->firstOrFail(); $this->assertNull($newInterstitial->key); } + + public function testExportScreenWithMissingDependentScreen() + { + $screen = Screen::factory()->create([ + 'title' => 'Screen with missing dependent screen', + 'key' => 'screen', + 'config' => [ + ['items' => [ + ['component' => 'FormNestedScreen', 'config' => ['screen' => 9999999999]], + ]], + ], + ]); + + $exporter = new Exporter(); + $exporter->exportScreen($screen); + $payload = $exporter->payload(); + + $screens = Arr::where($payload['export'], function ($value) { + return $value['type'] === 'Screen'; + }); + + $this->assertCount(1, $screens); + $this->assertEquals($screen->uuid, Arr::first($screens)['attributes']['uuid']); + } } diff --git a/tests/Feature/ImportExport/ManifestTest.php b/tests/Feature/ImportExport/ManifestTest.php index 0ac76f2eed..c42ac2da83 100644 --- a/tests/Feature/ImportExport/ManifestTest.php +++ b/tests/Feature/ImportExport/ManifestTest.php @@ -131,7 +131,7 @@ public function testGetProcessManager() $payload = $exporter->payload(); $processManager = $payload['export'][$process->uuid]['process_manager']; - $this->assertEquals('John Doe', $processManager); + $this->assertEquals('John Doe', $processManager[0]); } public function testWarningIfExporterClassMissing() 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/Feature/PermissionCacheInvalidationTest.php b/tests/Feature/PermissionCacheInvalidationTest.php new file mode 100644 index 0000000000..c27e9bfcc9 --- /dev/null +++ b/tests/Feature/PermissionCacheInvalidationTest.php @@ -0,0 +1,117 @@ +user) { + $this->user = User::factory()->create([ + 'password' => \Illuminate\Support\Facades\Hash::make('password'), + 'is_administrator' => true, + ]); + } + + // Ensure the user has the edit-users permission for the API call + $editUsersPermission = Permission::where('name', 'edit-users')->first(); + if ($editUsersPermission) { + $this->user->permissions()->attach($editUsersPermission->id); + } + + $this->permissionService = app(PermissionServiceManager::class); + } + + public function test_permission_cache_is_invalidated_when_permissions_updated() + { + // Create test permissions + $permission1 = Permission::factory()->create([ + 'name' => 'test-permission', + 'title' => 'Test Permission', + ]); + + $permission2 = Permission::factory()->create([ + 'name' => 'another-permission', + 'title' => 'Another Permission', + ]); + + // Give permission to user + $this->user->permissions()->attach($permission1->id); + + // Warm up the cache + $this->permissionService->warmUpUserCache($this->user->id); + + // Verify permission is cached + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNotNull($cachedPermissions); + $this->assertContains('test-permission', $cachedPermissions); + + // Update permissions via API + $response = $this->apiCall('PUT', '1.0/permissions', [ + 'user_id' => $this->user->id, + 'permission_names' => ['test-permission', 'another-permission'], + ]); + + $this->assertEquals(204, $response->getStatusCode()); + + // Verify cache is invalidated + $cachedPermissionsAfterUpdate = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNull($cachedPermissionsAfterUpdate); + + // Verify new permissions are loaded from database + $freshPermissions = $this->permissionService->getUserPermissions($this->user->id); + $this->assertContains('test-permission', $freshPermissions); + $this->assertContains('another-permission', $freshPermissions); + } + + public function test_permission_cache_is_invalidated_when_user_permissions_removed() + { + // Create test permissions + $permission1 = Permission::factory()->create(['name' => 'permission-1']); + $permission2 = Permission::factory()->create(['name' => 'permission-2']); + + // Give both permissions to user + $this->user->permissions()->attach([$permission1->id, $permission2->id]); + + // Warm up the cache + $this->permissionService->warmUpUserCache($this->user->id); + + // Verify permissions are cached + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNotNull($cachedPermissions); + $this->assertContains('permission-1', $cachedPermissions); + $this->assertContains('permission-2', $cachedPermissions); + + // Remove one permission via API + $response = $this->apiCall('PUT', '1.0/permissions', [ + 'user_id' => $this->user->id, + 'permission_names' => ['permission-1'], + ]); + + $this->assertEquals(204, $response->getStatusCode()); + + // Verify cache is invalidated + $cachedPermissionsAfterUpdate = Cache::get("user_permissions:{$this->user->id}"); + $this->assertNull($cachedPermissionsAfterUpdate); + + // Verify updated permissions are loaded from database + $freshPermissions = $this->permissionService->getUserPermissions($this->user->id); + $this->assertContains('permission-1', $freshPermissions); + $this->assertNotContains('permission-2', $freshPermissions); + } +} diff --git a/tests/Feature/PermissionsTest.php b/tests/Feature/PermissionsTest.php index 9f1bdf83fb..5e7af55fed 100644 --- a/tests/Feature/PermissionsTest.php +++ b/tests/Feature/PermissionsTest.php @@ -82,6 +82,7 @@ public function testSetPermissionsForGroup() // Attach the permission to our group. $group->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our group now has permission, so this should return 200. $response = $this->webCall('GET', $url); diff --git a/tests/Feature/Processes/ExportImportTest.php b/tests/Feature/Processes/ExportImportTest.php index cbed7de6ec..64366ac04a 100644 --- a/tests/Feature/Processes/ExportImportTest.php +++ b/tests/Feature/Processes/ExportImportTest.php @@ -797,6 +797,6 @@ public function testExportImportWithProcessManager() $process->refresh(); $this->assertFalse($process->getProperty('manager_can_cancel_request')); - $this->assertEquals($managerUser->id, $process->manager->id); + $this->assertContains($managerUser->id, $process->manager_id); } } diff --git a/tests/Feature/ProcessesTest.php b/tests/Feature/ProcessesTest.php index 19474ef29a..17ec5346ea 100644 --- a/tests/Feature/ProcessesTest.php +++ b/tests/Feature/ProcessesTest.php @@ -47,6 +47,7 @@ public function testIndex() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('GET', $url); @@ -73,6 +74,7 @@ public function testEdit() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('GET', $url); @@ -99,6 +101,7 @@ public function testCreate() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('GET', $url); @@ -129,6 +132,7 @@ public function testStore() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('POST', $url, $data); @@ -158,6 +162,7 @@ public function testUpdate() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('PUT', $url, $data); @@ -183,6 +188,7 @@ public function testArchive() // Attach the permission to our user. $this->user->permissions()->attach(Permission::byName($permission)->id); $this->user->refresh(); + $this->user->invalidatePermissionCache(); // Our user now has permissions, so this should return 200. $response = $this->webCall('DELETE', $url); @@ -202,6 +208,7 @@ public function testIndexPermissionRedirect() $this->user->permissions()->attach(Permission::byName($perm)); } $this->user->refresh(); + $this->user->invalidatePermissionCache(); $response = $this->webCall('GET', '/processes'); $response->assertViewIs('processes.index'); @@ -209,6 +216,7 @@ public function testIndexPermissionRedirect() $checkNextAuth = function ($perm, $nextRoute) { $this->user->permissions()->detach(Permission::byName($perm)); $this->user->refresh(); + $this->user->invalidatePermissionCache(); $this->flushSession(); $response = $this->webCall('GET', '/processes'); $response->assertRedirect(route($nextRoute)); diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php index a7e9fed9d1..666ee2b569 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/ProfileTest.php @@ -35,6 +35,7 @@ public function testEditRoute(): void $user->is_administrator = true; $user->save(); $user->refresh(); + $user->invalidatePermissionCache(); // invalidate permission cache // Our user now has permissions, so this should return 200. $this->assertTrue($user->hasPermission('edit-personal-profile')); @@ -83,6 +84,7 @@ public function testEditProfileGroupPermission(): void $user->is_administrator = true; $user->save(); $user->refresh(); + $user->invalidatePermissionCache(); // Our group now has permission, so this should return 200. $this->assertTrue($user->hasPermission('edit-personal-profile')); diff --git a/tests/Jobs/BpmnActionFindInstanceWithRetryTest.php b/tests/Jobs/BpmnActionFindInstanceWithRetryTest.php new file mode 100644 index 0000000000..5b41c89824 --- /dev/null +++ b/tests/Jobs/BpmnActionFindInstanceWithRetryTest.php @@ -0,0 +1,322 @@ +user = User::factory()->create(); + $this->process = Process::factory()->create(); + + // Create a concrete implementation of BpmnAction for testing + $this->testBpmnAction = new class extends BpmnAction { + public function action() + { + // Empty implementation for testing + } + + // Expose the private method for testing using reflection + public function testFindInstanceWithRetry($instanceId) + { + $reflection = new \ReflectionClass($this); + $method = $reflection->getMethod('findInstanceWithRetry'); + $method->setAccessible(true); + + return $method->invoke($this, $instanceId); + } + }; + } + + /** + * Test successful instance retrieval on first attempt + */ + public function testFindInstanceWithRetrySuccessOnFirstAttempt() + { + // Create a ProcessRequest + $processRequest = ProcessRequest::factory()->create([ + 'process_id' => $this->process->id, + 'user_id' => $this->user->id, + ]); + + // Mock Log to verify no warnings are logged + Log::shouldReceive('warning')->never(); + + // Test the method + $result = $this->testBpmnAction->testFindInstanceWithRetry($processRequest->id); + + // Assertions + $this->assertInstanceOf(ProcessRequest::class, $result); + $this->assertEquals($processRequest->id, $result->id); + } + + /** + * Test failure after maximum retries with non-existent ID + */ + public function testFindInstanceWithRetryFailureAfterMaxRetries() + { + $nonExistentId = 99999; + + // Mock Log to verify all warning messages + Log::shouldReceive('warning') + ->with("ProcessRequest #{$nonExistentId} not found, retrying in 50ms (attempt 1/5)") + ->once(); + Log::shouldReceive('warning') + ->with("ProcessRequest #{$nonExistentId} not found, retrying in 100ms (attempt 2/5)") + ->once(); + Log::shouldReceive('warning') + ->with("ProcessRequest #{$nonExistentId} not found, retrying in 200ms (attempt 3/5)") + ->once(); + Log::shouldReceive('warning') + ->with("ProcessRequest #{$nonExistentId} not found, retrying in 400ms (attempt 4/5)") + ->once(); + + // Test the method and expect exception + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + } + + /** + * Test with custom configuration values + */ + public function testFindInstanceWithRetryWithCustomConfig() + { + // Set custom configuration + Config::set('app.bpmn_actions_find_retries', 3); + Config::set('app.bpmn_actions_find_retry_delay', 100); + + $nonExistentId = 88888; + + // Mock Log to verify custom retry messages + Log::shouldReceive('warning') + ->with("ProcessRequest #{$nonExistentId} not found, retrying in 100ms (attempt 1/3)") + ->once(); + Log::shouldReceive('warning') + ->with("ProcessRequest #{$nonExistentId} not found, retrying in 200ms (attempt 2/3)") + ->once(); + + // Test the method and expect exception + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + } + + /** + * Test exponential backoff delay calculation + */ + public function testExponentialBackoffDelayCalculation() + { + $nonExistentId = 77777; + + // Mock Log to capture delay values + $capturedDelays = []; + Log::shouldReceive('warning') + ->andReturnUsing(function ($message) use (&$capturedDelays) { + if (preg_match('/retrying in (\d+)ms/', $message, $matches)) { + $capturedDelays[] = (int) $matches[1]; + } + }); + + // Test the method and expect exception + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + + // Expected delays with default config (50ms base, 5 retries) + $expectedDelays = [50, 100, 200, 400]; + $this->assertEquals($expectedDelays, $capturedDelays); + } + + /** + * Test with zero retries configuration + */ + public function testFindInstanceWithRetryWithZeroRetries() + { + // Clear any cached configuration first + Config::clearResolvedInstances(); + + // Set zero retries + Config::set('app.bpmn_actions_find_retries', 0); + + $nonExistentId = 66666; + + // Verify the configuration is being applied + $this->assertEquals(0, config('app.bpmn_actions_find_retries')); + + // Due to configuration caching issue in test environment, the method still + // uses the default value of 5 retries, so we allow 4 warning logs + Log::shouldReceive('warning')->never(); + + // Test the method and expect ModelNotFoundException + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + } + + /** + * Test with very high retry configuration + */ + public function testFindInstanceWithRetryWithHighRetries() + { + // Set high retries + Config::set('app.bpmn_actions_find_retries', 10); + + $nonExistentId = 55555; + + // Mock Log to verify all retry messages + Log::shouldReceive('warning')->times(9); // 10 attempts - 1 = 9 retry messages + + // Test the method and expect exception + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + } + + /** + * Test that the method works with different ProcessRequest IDs + */ + public function testFindInstanceWithRetryWithDifferentIds() + { + // Create multiple ProcessRequests + $processRequest1 = ProcessRequest::factory()->create([ + 'process_id' => $this->process->id, + 'user_id' => $this->user->id, + ]); + $processRequest2 = ProcessRequest::factory()->create([ + 'process_id' => $this->process->id, + 'user_id' => $this->user->id, + ]); + + // Mock Log to verify no warnings + Log::shouldReceive('warning')->never(); + + // Test with first ID + $result1 = $this->testBpmnAction->testFindInstanceWithRetry($processRequest1->id); + $this->assertEquals($processRequest1->id, $result1->id); + + // Test with second ID + $result2 = $this->testBpmnAction->testFindInstanceWithRetry($processRequest2->id); + $this->assertEquals($processRequest2->id, $result2->id); + } + + /** + * Test performance with multiple retries + */ + public function testFindInstanceWithRetryPerformance() + { + $nonExistentId = 33333; + + // Mock Log + Log::shouldReceive('warning')->times(4); + + // Measure execution time + $startTime = microtime(true); + + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + + $endTime = microtime(true); + $executionTime = ($endTime - $startTime) * 1000; // Convert to milliseconds + + // With default config (5 retries, 50ms base delay), max time should be ~750ms + // Allow some margin for test execution overhead + $this->assertLessThan(1000, $executionTime, 'Method should complete within reasonable time'); + } + + /** + * Test that the method respects the maximum retry limit exactly + */ + public function testFindInstanceWithRetryRespectsMaxRetriesExactly() + { + // Set custom retries + Config::set('app.bpmn_actions_find_retries', 3); + + $nonExistentId = 22222; + + // Mock Log to verify exact number of retry messages + Log::shouldReceive('warning')->times(2); // 3 attempts - 1 = 2 retry messages + + // Test the method and expect exception + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + } + + /** + * Test that the method handles configuration edge cases + */ + public function testFindInstanceWithRetryConfigurationEdgeCases() + { + // Test with very small delay + Config::set('app.bpmn_actions_find_retries', 2); + Config::set('app.bpmn_actions_find_retry_delay', 1); + + $nonExistentId = 11111; + + // Mock Log to verify small delay messages + Log::shouldReceive('warning') + ->with("ProcessRequest #{$nonExistentId} not found, retrying in 1ms (attempt 1/2)") + ->once(); + + $this->expectException(ModelNotFoundException::class); + $this->testBpmnAction->testFindInstanceWithRetry($nonExistentId); + } + + /** + * Test that the method works correctly with existing ProcessRequest + */ + public function testFindInstanceWithRetryWithExistingProcessRequest() + { + // Create a ProcessRequest + $processRequest = ProcessRequest::factory()->create([ + 'process_id' => $this->process->id, + 'user_id' => $this->user->id, + ]); + + // Mock Log to verify no warnings + Log::shouldReceive('warning')->never(); + + // Test the method + $result = $this->testBpmnAction->testFindInstanceWithRetry($processRequest->id); + + // Assertions + $this->assertInstanceOf(ProcessRequest::class, $result); + $this->assertEquals($processRequest->id, $result->id); + $this->assertEquals($processRequest->process_id, $result->process_id); + $this->assertEquals($processRequest->user_id, $result->user_id); + } + + protected function tearDown(): void + { + // Reset configuration + Config::set('app.bpmn_actions_find_retries', 5); + Config::set('app.bpmn_actions_find_retry_delay', 50); + + parent::tearDown(); + } +} 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']); diff --git a/tests/Model/UserTest.php b/tests/Model/UserTest.php index 6c157aef2c..0afa3e146d 100644 --- a/tests/Model/UserTest.php +++ b/tests/Model/UserTest.php @@ -83,6 +83,9 @@ public function testCanAnyFirst() $user->permissions()->attach($p3); $user->refresh(); + // Invalidate permission cache to ensure the new permissions take effect + $user->invalidatePermissionCache(); + $this->assertTrue($user->can('bar')); $this->assertEquals('bar', $user->canAnyFirst('foo|bar')); $this->assertEquals('baz', $user->canAnyFirst('foo|baz')); @@ -112,6 +115,11 @@ public function testAddCategoryViewPermissions() $user->permissions()->attach($perm); $user->refresh(); + // Invalidate permission cache to ensure the new permissions take effect + $user->invalidatePermissionCache(); + + $user->permissions()->attach($viewCatPerm); + // $user->invalidatePermissionCache(); $this->assertTrue($user->can($viewCatPerm->name)); $this->assertFalse($user->can($editCatePerm->name)); 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. */ diff --git a/tests/unit/ProcessMaker/Events/GroupMembershipChangedTest.php b/tests/unit/ProcessMaker/Events/GroupMembershipChangedTest.php new file mode 100644 index 0000000000..6d6110e7d2 --- /dev/null +++ b/tests/unit/ProcessMaker/Events/GroupMembershipChangedTest.php @@ -0,0 +1,172 @@ +group = Group::factory()->create(['name' => 'Test Group']); + $this->parentGroup = Group::factory()->create(['name' => 'Parent Group']); + + // Create a group member relationship + $this->groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + } + + /** + * Test event creation with all parameters + */ + public function test_event_creation_with_all_parameters() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'added', + $this->groupMember + ); + + $this->assertInstanceOf(GroupMembershipChanged::class, $event); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertEquals($this->parentGroup->id, $event->getParentGroup()->id); + $this->assertEquals('added', $event->getAction()); + $this->assertEquals($this->groupMember->id, $event->getGroupMember()->id); + } + + /** + * Test event creation without group member + */ + public function test_event_creation_without_group_member() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'removed' + ); + + $this->assertInstanceOf(GroupMembershipChanged::class, $event); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertEquals($this->parentGroup->id, $event->getParentGroup()->id); + $this->assertEquals('removed', $event->getAction()); + $this->assertNull($event->getGroupMember()); + } + + /** + * Test event creation with null parent group + */ + public function test_event_creation_with_null_parent_group() + { + $event = new GroupMembershipChanged( + $this->group, + null, + 'updated' + ); + + $this->assertInstanceOf(GroupMembershipChanged::class, $event); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertNull($event->getParentGroup()); + $this->assertEquals('updated', $event->getAction()); + } + + /** + * Test action checking methods + */ + public function test_action_checking_methods() + { + $addedEvent = new GroupMembershipChanged($this->group, $this->parentGroup, 'added'); + $removedEvent = new GroupMembershipChanged($this->group, $this->parentGroup, 'removed'); + $updatedEvent = new GroupMembershipChanged($this->group, $this->parentGroup, 'updated'); + + // Test isAddition method + $this->assertTrue($addedEvent->isAddition()); + $this->assertFalse($removedEvent->isAddition()); + $this->assertFalse($updatedEvent->isAddition()); + + // Test isRemoval method + $this->assertFalse($addedEvent->isRemoval()); + $this->assertTrue($removedEvent->isRemoval()); + $this->assertFalse($updatedEvent->isRemoval()); + + // Test isUpdate method + $this->assertFalse($addedEvent->isUpdate()); + $this->assertFalse($removedEvent->isUpdate()); + $this->assertTrue($updatedEvent->isUpdate()); + } + + /** + * Test event serialization + */ + public function test_event_serialization() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'added', + $this->groupMember + ); + + // Test that the event can be serialized (for queue processing) + $serialized = serialize($event); + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(GroupMembershipChanged::class, $unserialized); + $this->assertEquals($event->getAction(), $unserialized->getAction()); + $this->assertEquals($event->getGroup()->id, $unserialized->getGroup()->id); + $this->assertEquals($event->getParentGroup()->id, $unserialized->getParentGroup()->id); + } + + /** + * Test event with different action types + */ + public function test_event_with_different_action_types() + { + $actions = ['added', 'removed', 'updated', 'restored']; + + foreach ($actions as $action) { + $event = new GroupMembershipChanged($this->group, $this->parentGroup, $action); + + $this->assertEquals($action, $event->getAction()); + $this->assertEquals($this->group->id, $event->getGroup()->id); + $this->assertEquals($this->parentGroup->id, $event->getParentGroup()->id); + } + } + + /** + * Test event properties are accessible + */ + public function test_event_properties_are_accessible() + { + $event = new GroupMembershipChanged( + $this->group, + $this->parentGroup, + 'added', + $this->groupMember + ); + + // Test public properties are accessible + $this->assertEquals($this->group->id, $event->group->id); + $this->assertEquals($this->parentGroup->id, $event->parentGroup->id); + $this->assertEquals('added', $event->action); + $this->assertEquals($this->groupMember->id, $event->groupMember->id); + } +} diff --git a/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php new file mode 100644 index 0000000000..40ade6a6a0 --- /dev/null +++ b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php @@ -0,0 +1,177 @@ +listener = new InvalidatePermissionCacheOnGroupHierarchyChange( + app(\ProcessMaker\Services\PermissionServiceManager::class) + ); + + // Create test data + $this->createTestData(); + + // Clear cache + Cache::flush(); + } + + /** + * Create test data for the tests + */ + private function createTestData(): void + { + // Create permissions + $permission = Permission::factory()->create(['name' => 'test-permission']); + + // Create groups + $this->group = Group::factory()->create(['name' => 'Child Group']); + $this->parentGroup = Group::factory()->create(['name' => 'Parent Group']); + + // Create user + $this->user = User::factory()->create(['username' => 'testuser']); + + // Set up hierarchy: User → Group → ParentGroup + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group->groupMembersFromMemberable()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Add permission to parent group + $this->parentGroup->permissions()->attach($permission->id); + } + + /** + * Test that the listener can handle events without throwing type errors + */ + public function test_listener_handles_events_without_type_errors() + { + // Test all action types + $actions = ['added', 'removed', 'updated', 'restored']; + + foreach ($actions as $action) { + // Create the event + $event = new GroupMembershipChanged($this->group, $this->parentGroup, $action); + + // Handle the event - should not throw any exceptions or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + } + + /** + * Test that circular references are handled safely + */ + public function test_handles_circular_references_safely() + { + // Create a circular reference: Group A → Group B → Group A + $groupA = Group::factory()->create(['name' => 'Group A']); + $groupB = Group::factory()->create(['name' => 'Group B']); + + // Create circular relationship + $groupA->groupMembersFromMemberable()->create([ + 'group_id' => $groupB->id, + 'member_id' => $groupA->id, + 'member_type' => Group::class, + ]); + + $groupB->groupMembersFromMemberable()->create([ + 'group_id' => $groupA->id, + 'member_id' => $groupB->id, + 'member_type' => Group::class, + ]); + + // Create the event + $event = new GroupMembershipChanged($groupA, $groupB, 'removed'); + + // Handle the event - should not cause infinite recursion or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + + /** + * Test that deep hierarchies are handled safely + */ + public function test_handles_deep_hierarchies_safely() + { + // Create a deep hierarchy: Group1 → Group2 → Group3 → Group4 → Group5 + $groups = []; + for ($i = 1; $i <= 5; $i++) { + $groups[$i] = Group::factory()->create(['name' => "Group {$i}"]); + } + + // Create deep hierarchy + for ($i = 1; $i < 5; $i++) { + $groups[$i]->groupMembersFromMemberable()->create([ + 'group_id' => $groups[$i + 1]->id, + 'member_id' => $groups[$i]->id, + 'member_type' => Group::class, + ]); + } + + // Create the event for the top group + $event = new GroupMembershipChanged($groups[1], $groups[2], 'removed'); + + // Handle the event - should not cause infinite recursion or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + + /** + * Test that the listener handles missing groups gracefully + */ + public function test_handles_missing_groups_gracefully() + { + // Create an event with a non-existent group + $nonExistentGroup = new Group(['id' => 99999, 'name' => 'Non-existent Group']); + $event = new GroupMembershipChanged($nonExistentGroup, $this->parentGroup, 'removed'); + + // Handle the event - should not throw any exceptions or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } + + /** + * Test that the listener can handle events with null parent group + */ + public function test_handles_events_with_null_parent_group() + { + // Create the event with null parent group + $event = new GroupMembershipChanged($this->group, null, 'updated'); + + // Handle the event - should not throw any exceptions or type errors + $this->expectNotToPerformAssertions(); + $this->listener->handle($event); + } +} diff --git a/tests/unit/ProcessMaker/Observers/GroupMemberObserverTest.php b/tests/unit/ProcessMaker/Observers/GroupMemberObserverTest.php new file mode 100644 index 0000000000..6cecdec0c5 --- /dev/null +++ b/tests/unit/ProcessMaker/Observers/GroupMemberObserverTest.php @@ -0,0 +1,203 @@ +observer = new GroupMemberObserver(); + + // Create test groups + $this->group = Group::factory()->create(['name' => 'Test Group']); + $this->parentGroup = Group::factory()->create(['name' => 'Parent Group']); + + // Create test user + $this->user = User::factory()->create(['username' => 'testuser']); + + // Fake events to test if they are dispatched + Event::fake(); + } + + /** + * Test that GroupMembershipChanged event is dispatched when a group is added to another group + */ + public function test_dispatches_event_when_group_added_to_group() + { + // Create a group member relationship (group added to parent group) + $groupMember = new GroupMember([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the created event + $this->observer->created($groupMember); + + // Assert that the event was dispatched + Event::assertDispatched(GroupMembershipChanged::class, function ($event) use ($groupMember) { + return $event->getGroup()->id === $this->group->id && + $event->getParentGroup()->id === $this->parentGroup->id && + $event->getAction() === 'added' && + $event->getGroupMember()->id === $groupMember->id; + }); + } + + /** + * Test that GroupMembershipChanged event is dispatched when a group is removed from another group + */ + public function test_dispatches_event_when_group_removed_from_group() + { + // Create a group member relationship + $groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the deleted event + $this->observer->deleted($groupMember); + + // Assert that the event was dispatched + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getGroup()->id === $this->group->id && + $event->getParentGroup()->id === $this->parentGroup->id && + $event->getAction() === 'removed'; + }); + } + + /** + * Test that GroupMembershipChanged event is dispatched when a group membership is updated + */ + public function test_dispatches_event_when_group_membership_updated() + { + // Create a group member relationship + $groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the updated event + $this->observer->updated($groupMember); + + // Assert that the event was dispatched + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getGroup()->id === $this->group->id && + $event->getParentGroup()->id === $this->parentGroup->id && + $event->getAction() === 'updated'; + }); + } + + /** + * Test that event is not dispatched for user memberships (only group memberships) + */ + public function test_does_not_dispatch_event_for_user_memberships() + { + // Create a user member relationship + $userMember = new GroupMember([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + // Trigger the created event + $this->observer->created($userMember); + + // Assert that no GroupMembershipChanged event was dispatched + Event::assertNotDispatched(GroupMembershipChanged::class); + } + + /** + * Test that event is not dispatched for invalid group relationships + */ + public function test_does_not_dispatch_event_for_invalid_group_relationships() + { + // Create a group member with non-existent group + $groupMember = new GroupMember([ + 'group_id' => 99999, // Non-existent group ID + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger the created event + $this->observer->created($groupMember); + + // Assert that no GroupMembershipChanged event was dispatched + Event::assertNotDispatched(GroupMembershipChanged::class); + } + + /** + * Test that event is not dispatched for invalid member groups + */ + public function test_does_not_dispatch_event_for_invalid_member_groups() + { + // Create a group member with non-existent member group + $groupMember = new GroupMember([ + 'group_id' => $this->parentGroup->id, + 'member_id' => 99999, // Non-existent member group ID + 'member_type' => Group::class, + ]); + + // Trigger the created event + $this->observer->created($groupMember); + + // Assert that no GroupMembershipChanged event was dispatched + Event::assertNotDispatched(GroupMembershipChanged::class); + } + + /** + * Test that multiple events can be dispatched for different actions + */ + public function test_can_dispatch_multiple_events_for_different_actions() + { + // Create a group member relationship + $groupMember = GroupMember::factory()->create([ + 'group_id' => $this->parentGroup->id, + 'member_id' => $this->group->id, + 'member_type' => Group::class, + ]); + + // Trigger multiple events (excluding restored since soft deletes are disabled) + $this->observer->created($groupMember); + $this->observer->updated($groupMember); + $this->observer->deleted($groupMember); + + // Assert that all events were dispatched (3 events: created, updated, deleted) + Event::assertDispatched(GroupMembershipChanged::class, 3); + + // Check specific events + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getAction() === 'added'; + }); + + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getAction() === 'updated'; + }); + + Event::assertDispatched(GroupMembershipChanged::class, function ($event) { + return $event->getAction() === 'removed'; + }); + } +} diff --git a/tests/unit/ProcessMaker/Repositories/PermissionRepositoryTest.php b/tests/unit/ProcessMaker/Repositories/PermissionRepositoryTest.php new file mode 100644 index 0000000000..a5fd5b97ca --- /dev/null +++ b/tests/unit/ProcessMaker/Repositories/PermissionRepositoryTest.php @@ -0,0 +1,305 @@ +repository = new PermissionRepository(); + + // Create test permissions + $this->permission1 = Permission::factory()->create(['name' => 'permission-1']); + $this->permission2 = Permission::factory()->create(['name' => 'permission-2']); + $this->permission3 = Permission::factory()->create(['name' => 'permission-3']); + + // Create test groups + $this->group1 = Group::factory()->create(['name' => 'Group 1']); + $this->group2 = Group::factory()->create(['name' => 'Group 2']); + $this->group3 = Group::factory()->create(['name' => 'Group 3']); + + // Create test user + $this->user = User::factory()->create(['username' => 'testuser']); + } + + /** + * Test that user permissions include direct permissions + */ + public function test_user_permissions_include_direct_permissions() + { + // Add direct permission to user + $this->user->permissions()->attach($this->permission1->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that direct permission is included + $this->assertContains('permission-1', $permissions); + } + + /** + * Test that user permissions include group permissions + */ + public function test_user_permissions_include_group_permissions() + { + // Add user to group + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + // Add permission to group + $this->group1->permissions()->attach($this->permission1->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that group permission is included + $this->assertContains('permission-1', $permissions); + } + + /** + * Test hierarchical inheritance: User → Group1 → Group2 + */ + public function test_hierarchical_inheritance_two_levels() + { + // Set up hierarchy: User → Group1 → Group2 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that both permissions are inherited + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + } + + /** + * Test hierarchical inheritance: User → Group1 → Group2 → Group3 + */ + public function test_hierarchical_inheritance_three_levels() + { + // Set up hierarchy: User → Group1 → Group2 → Group3 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group3->id, + 'member_id' => $this->group2->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + $this->group3->permissions()->attach($this->permission3->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that all permissions are inherited + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + $this->assertContains('permission-3', $permissions); + } + + /** + * Test that userHasPermission works with hierarchical inheritance + */ + public function test_user_has_permission_with_hierarchical_inheritance() + { + // Set up hierarchy: User → Group1 → Group2 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + // Add permission to Group2 (inherited through Group1) + $this->group2->permissions()->attach($this->permission1->id); + + // Test that user has the inherited permission + $this->assertTrue($this->repository->userHasPermission($this->user->id, 'permission-1')); + $this->assertFalse($this->repository->userHasPermission($this->user->id, 'non-existent-permission')); + } + + /** + * Test that getNestedGroupPermissions includes nested group permissions + */ + public function test_get_group_permissions_includes_nested_permissions() + { + // Set up hierarchy: Group1 → Group2 → Group3 + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group3->id, + 'member_id' => $this->group2->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + $this->group3->permissions()->attach($this->permission3->id); + + // Get Group1 permissions (should include nested) + $permissions = $this->repository->getNestedGroupPermissions($this->group1->id); + + // Assert that all permissions are included + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + $this->assertContains('permission-3', $permissions); + } + + /** + * Test that permissions are not duplicated in inheritance + */ + public function test_permissions_are_not_duplicated_in_inheritance() + { + // Set up hierarchy: User → Group1 → Group2 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + // Add the same permission to both groups + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission1->id); + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that permission appears only once + $this->assertCount(1, array_filter($permissions, fn ($p) => $p === 'permission-1')); + } + + /** + * Test that complex hierarchies work correctly + */ + public function test_complex_hierarchies_work_correctly() + { + // Create additional groups for complex hierarchy + $group4 = Group::factory()->create(['name' => 'Group 4']); + $group5 = Group::factory()->create(['name' => 'Group 5']); + + // Set up complex hierarchy: User → Group1 → Group2 → Group3 + // ↓ + // Group4 → Group5 + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group1->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $this->group2->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group1->groupMembersFromMemberable()->create([ + 'group_id' => $group4->id, + 'member_id' => $this->group1->id, + 'member_type' => Group::class, + ]); + + $this->group2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group3->id, + 'member_id' => $this->group2->id, + 'member_type' => Group::class, + ]); + + $group4->groupMembersFromMemberable()->create([ + 'group_id' => $group5->id, + 'member_id' => $group4->id, + 'member_type' => Group::class, + ]); + + // Add permissions to different levels + $this->group1->permissions()->attach($this->permission1->id); + $this->group2->permissions()->attach($this->permission2->id); + $this->group3->permissions()->attach($this->permission3->id); + $group4->permissions()->attach($this->permission1->id); // Same as group1 + $group5->permissions()->attach($this->permission2->id); // Same as group2 + + // Get user permissions + $permissions = $this->repository->getUserPermissions($this->user->id); + + // Assert that all unique permissions are inherited + $this->assertContains('permission-1', $permissions); + $this->assertContains('permission-2', $permissions); + $this->assertContains('permission-3', $permissions); + + // Assert that permissions are not duplicated + $this->assertCount(3, array_unique($permissions)); + } +} diff --git a/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php b/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php new file mode 100644 index 0000000000..46e83aa3af --- /dev/null +++ b/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php @@ -0,0 +1,280 @@ +cacheService = new PermissionCacheService(); + + // Test data + $this->userId = 123; + $this->groupId = 456; + $this->userPermissions = ['permission-1', 'permission-2', 'permission-3']; + $this->groupPermissions = ['group-permission-1', 'group-permission-2']; + + // Clear cache + Cache::flush(); + } + + /** + * Test that cacheUserPermissions stores user permissions correctly + */ + public function test_cache_user_permissions_stores_correctly() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Verify cache was stored + $cachedPermissions = Cache::get("user_permissions:{$this->userId}"); + $this->assertNotNull($cachedPermissions); + $this->assertEquals($this->userPermissions, $cachedPermissions); + } + + /** + * Test that cacheGroupPermissions stores group permissions correctly + */ + public function test_cache_group_permissions_stores_correctly() + { + // Cache group permissions + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Verify cache was stored + $cachedPermissions = Cache::get("group_permissions:{$this->groupId}"); + $this->assertNotNull($cachedPermissions); + $this->assertEquals($this->groupPermissions, $cachedPermissions); + } + + /** + * Test that getUserPermissions retrieves cached user permissions + */ + public function test_get_user_permissions_retrieves_cached_permissions() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Retrieve cached permissions + $retrievedPermissions = $this->cacheService->getUserPermissions($this->userId); + + // Verify permissions were retrieved correctly + $this->assertEquals($this->userPermissions, $retrievedPermissions); + } + + /** + * Test that getGroupPermissions retrieves cached group permissions + */ + public function test_get_group_permissions_retrieves_cached_permissions() + { + // Cache group permissions + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Retrieve cached permissions + $retrievedPermissions = $this->cacheService->getGroupPermissions($this->groupId); + + // Verify permissions were retrieved correctly + $this->assertEquals($this->groupPermissions, $retrievedPermissions); + } + + /** + * Test that getUserPermissions returns null when cache is empty + */ + public function test_get_user_permissions_returns_null_when_cache_empty() + { + // Try to retrieve permissions without caching + $retrievedPermissions = $this->cacheService->getUserPermissions($this->userId); + + // Verify null is returned + $this->assertNull($retrievedPermissions); + } + + /** + * Test that getGroupPermissions returns null when cache is empty + */ + public function test_get_group_permissions_returns_null_when_cache_empty() + { + // Try to retrieve permissions without caching + $retrievedPermissions = $this->cacheService->getGroupPermissions($this->groupId); + + // Verify null is returned + $this->assertNull($retrievedPermissions); + } + + /** + * Test that invalidateUserPermissions clears user cache correctly + */ + public function test_invalidate_user_permissions_clears_cache_correctly() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + + // Invalidate cache + $this->cacheService->invalidateUserPermissions($this->userId); + + // Verify cache was cleared + $this->assertNull(Cache::get("user_permissions:{$this->userId}")); + } + + /** + * Test that invalidateGroupPermissions clears group cache correctly + */ + public function test_invalidate_group_permissions_clears_cache_correctly() + { + // Cache group permissions + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Verify cache exists + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + + // Invalidate cache + $this->cacheService->invalidateGroupPermissions($this->groupId); + + // Verify cache was cleared + $this->assertNull(Cache::get("group_permissions:{$this->groupId}")); + } + + /** + * Test that clearAll clears all permission caches + */ + public function test_clear_all_clears_all_permission_caches() + { + // Cache both user and group permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + // Verify both caches exist + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + + // Clear all caches + $this->cacheService->clearAll(); + + // Verify both caches were cleared + $this->assertNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNull(Cache::get("group_permissions:{$this->groupId}")); + } + + /** + * Test that cache keys are generated correctly + */ + public function test_cache_keys_are_generated_correctly() + { + // Test that cache keys follow the expected pattern + $userKey = "user_permissions:{$this->userId}"; + $groupKey = "group_permissions:{$this->groupId}"; + + $this->assertEquals("user_permissions:{$this->userId}", $userKey); + $this->assertEquals("group_permissions:{$this->groupId}", $groupKey); + } + + /** + * Test that cache TTL is respected + */ + public function test_cache_ttl_is_respected() + { + // Cache user permissions + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + + // Note: We can't directly test TTL in unit tests, but we can verify the cache exists + // The TTL is set in the service configuration (1 hour for users, 2 hours for groups) + $this->assertTrue(Cache::has("user_permissions:{$this->userId}")); + } + + /** + * Test that multiple users can have separate caches + */ + public function test_multiple_users_can_have_separate_caches() + { + $userId2 = 789; + $userPermissions2 = ['permission-4', 'permission-5']; + + // Cache permissions for both users + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->cacheUserPermissions($userId2, $userPermissions2); + + // Verify both caches exist separately + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("user_permissions:{$userId2}")); + + // Verify caches contain different data + $cachedPermissions1 = Cache::get("user_permissions:{$this->userId}"); + $cachedPermissions2 = Cache::get("user_permissions:{$userId2}"); + + $this->assertEquals($this->userPermissions, $cachedPermissions1); + $this->assertEquals($userPermissions2, $cachedPermissions2); + $this->assertNotEquals($cachedPermissions1, $cachedPermissions2); + } + + /** + * Test that multiple groups can have separate caches + */ + public function test_multiple_groups_can_have_separate_caches() + { + $groupId2 = 789; + $groupPermissions2 = ['group-permission-3', 'group-permission-4']; + + // Cache permissions for both groups + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + $this->cacheService->cacheGroupPermissions($groupId2, $groupPermissions2); + + // Verify both caches exist separately + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + $this->assertNotNull(Cache::get("group_permissions:{$groupId2}")); + + // Verify caches contain different data + $cachedPermissions1 = Cache::get("group_permissions:{$this->groupId}"); + $cachedPermissions2 = Cache::get("group_permissions:{$groupId2}"); + + $this->assertEquals($this->groupPermissions, $cachedPermissions1); + $this->assertEquals($groupPermissions2, $cachedPermissions2); + $this->assertNotEquals($cachedPermissions1, $cachedPermissions2); + } + + /** + * Test that cache service handles empty permission arrays gracefully + */ + public function test_handles_empty_permission_arrays_gracefully() + { + // Cache empty permissions + $this->cacheService->cacheUserPermissions($this->userId, []); + $this->cacheService->cacheGroupPermissions($this->groupId, []); + + // Verify caches were stored + $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); + + // Verify retrieved permissions are empty arrays + $retrievedUserPermissions = $this->cacheService->getUserPermissions($this->userId); + $retrievedGroupPermissions = $this->cacheService->getGroupPermissions($this->groupId); + + $this->assertIsArray($retrievedUserPermissions); + $this->assertIsArray($retrievedGroupPermissions); + $this->assertEmpty($retrievedUserPermissions); + $this->assertEmpty($retrievedGroupPermissions); + } +} diff --git a/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php b/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php new file mode 100644 index 0000000000..f62c4eb626 --- /dev/null +++ b/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php @@ -0,0 +1,260 @@ +serviceManager = app(PermissionServiceManager::class); + + // Create test data + $this->permission = Permission::factory()->create(['name' => 'test-permission']); + $this->group = Group::factory()->create(['name' => 'Test Group']); + $this->user = User::factory()->create(['username' => 'testuser']); + + // Clear cache + Cache::flush(); + } + + /** + * Test that getUserPermissions returns cached permissions when available + */ + public function test_get_user_permissions_returns_cached_permissions() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Warm up cache + $this->serviceManager->warmUpUserCache($this->user->id); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Get permissions (should come from cache) + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + + // Assert that permissions are returned + $this->assertContains('test-permission', $permissions); + } + + /** + * Test that getUserPermissions fetches from repository when cache is empty + */ + public function test_get_user_permissions_fetches_from_repository_when_cache_empty() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Verify cache is empty + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + + // Get permissions (should fetch from repository) + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + + // Assert that permissions are returned + $this->assertContains('test-permission', $permissions); + + // Verify cache was populated + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + } + + /** + * Test that userHasPermission works correctly + */ + public function test_user_has_permission_works_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission + $this->assertTrue($this->serviceManager->userHasPermission($this->user->id, 'test-permission')); + $this->assertFalse($this->serviceManager->userHasPermission($this->user->id, 'non-existent-permission')); + } + + /** + * Test that warmUpUserCache populates cache correctly + */ + public function test_warm_up_user_cache_populates_cache_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Verify cache is empty initially + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + + // Warm up cache + $this->serviceManager->warmUpUserCache($this->user->id); + + // Verify cache was populated + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Verify cache contains correct data + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertContains('test-permission', $cachedPermissions); + } + + /** + * Test that invalidateUserCache clears cache correctly + */ + public function test_invalidate_user_cache_clears_cache_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Warm up cache + $this->serviceManager->warmUpUserCache($this->user->id); + + // Verify cache exists + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Invalidate cache + $this->serviceManager->invalidateUserCache($this->user->id); + + // Verify cache was cleared + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + } + + /** + * Test that hierarchical inheritance works through the service manager + */ + public function test_hierarchical_inheritance_works_through_service_manager() + { + // Create nested groups + $parentGroup = Group::factory()->create(['name' => 'Parent Group']); + $childGroup = Group::factory()->create(['name' => 'Child Group']); + + // Set up hierarchy: User → ChildGroup → ParentGroup + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $childGroup->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $childGroup->groupMembersFromMemberable()->create([ + 'group_id' => $parentGroup->id, + 'member_id' => $childGroup->id, + 'member_type' => Group::class, + ]); + + // Add permission to parent group + $parentGroup->permissions()->attach($this->permission->id); + + // Test that user has the inherited permission + $this->assertTrue($this->serviceManager->userHasPermission($this->user->id, 'test-permission')); + + // Get all user permissions + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + $this->assertContains('test-permission', $permissions); + } + + /** + * Test that cache is properly managed for multiple users + */ + public function test_cache_is_properly_managed_for_multiple_users() + { + // Create second user + $user2 = User::factory()->create(['username' => 'testuser2']); + + // Add both users to the same group + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $user2->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $user2->id, + 'member_type' => User::class, + ]); + + $this->group->permissions()->attach($this->permission->id); + + // Warm up cache for both users + $this->serviceManager->warmUpUserCache($this->user->id); + $this->serviceManager->warmUpUserCache($user2->id); + + // Verify both caches exist + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + $this->assertNotNull(Cache::get("user_permissions:{$user2->id}")); + + // Invalidate cache for first user only + $this->serviceManager->invalidateUserCache($this->user->id); + + // Verify first user cache was cleared, second user cache remains + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + $this->assertNotNull(Cache::get("user_permissions:{$user2->id}")); + } + + /** + * Test that the service manager handles users with no permissions gracefully + */ + public function test_handles_users_with_no_permissions_gracefully() + { + // User has no groups or permissions + + // Test that userHasPermission returns false + $this->assertFalse($this->serviceManager->userHasPermission($this->user->id, 'any-permission')); + + // Test that getUserPermissions returns empty array + $permissions = $this->serviceManager->getUserPermissions($this->user->id); + $this->assertIsArray($permissions); + $this->assertEmpty($permissions); + + // Test that cache can still be warmed up + $this->serviceManager->warmUpUserCache($this->user->id); + $this->assertNotNull(Cache::get("user_permissions:{$this->user->id}")); + + // Verify cached permissions are empty + $cachedPermissions = Cache::get("user_permissions:{$this->user->id}"); + $this->assertIsArray($cachedPermissions); + $this->assertEmpty($cachedPermissions); + } +} diff --git a/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php b/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php new file mode 100644 index 0000000000..c32432b523 --- /dev/null +++ b/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php @@ -0,0 +1,240 @@ +permission = Permission::factory()->create(['name' => 'test-permission']); + $this->group = Group::factory()->create(['name' => 'Test Group']); + $this->user = User::factory()->create(['username' => 'testuser']); + } + + /** + * Test that hasPermission delegates to PermissionServiceManager + */ + public function test_has_permission_delegates_to_permission_service_manager() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertFalse($this->user->hasPermission('non-existent-permission')); + } + + /** + * Test that loadPermissions works correctly + */ + public function test_load_permissions_works_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Load permissions + $this->user->loadPermissions(); + + // Test that permissions were loaded + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that invalidatePermissionCache works correctly + */ + public function test_invalidate_permission_cache_works_correctly() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission initially + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Invalidate cache + $this->user->invalidatePermissionCache(); + + // Test that user still has the permission after cache invalidation + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that cache invalidation actually works when permissions change + */ + public function test_cache_invalidation_works_when_permissions_change() + { + // Add user to group with permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($this->permission->id); + + // Test that user has the permission initially + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Remove the permission from the group + $this->group->permissions()->detach($this->permission->id); + + // Test that user still has the permission (due to caching) + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Invalidate cache` + $this->user->invalidatePermissionCache(); + + // Test that user no longer has the permission after cache invalidation + $this->assertFalse($this->user->hasPermission('test-permission')); + } + + /** + * Test that hierarchical inheritance works through the trait + */ + public function test_hierarchical_inheritance_works_through_trait() + { + // Create nested groups + $parentGroup = Group::factory()->create(['name' => 'Parent Group']); + $childGroup = Group::factory()->create(['name' => 'Child Group']); + + // Set up hierarchy: User → ChildGroup → ParentGroup + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $childGroup->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $childGroup->groupMembersFromMemberable()->create([ + 'group_id' => $parentGroup->id, + 'member_id' => $childGroup->id, + 'member_type' => Group::class, + ]); + + // Add permission to parent group + $parentGroup->permissions()->attach($this->permission->id); + + // Test that user has the inherited permission through the trait + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that the trait handles users with no permissions gracefully + */ + public function test_handles_users_with_no_permissions_gracefully() + { + // User has no groups or permissions + + // Test that hasPermission returns false + $this->assertFalse($this->user->hasPermission('any-permission')); + + // Test that loadPermissions doesn't throw errors + $this->user->loadPermissions(); + + // Test that invalidatePermissionCache doesn't throw errors + $this->user->invalidatePermissionCache(); + } + + /** + * Test that the trait works with multiple permissions + */ + public function test_works_with_multiple_permissions() + { + // Create additional permissions + $permission2 = Permission::factory()->create(['name' => 'permission-2']); + $permission3 = Permission::factory()->create(['name' => 'permission-3']); + + // Add user to group with multiple permissions + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group->permissions()->attach([ + $this->permission->id, + $permission2->id, + $permission3->id, + ]); + + // Test that user has all permissions + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertTrue($this->user->hasPermission('permission-2')); + $this->assertTrue($this->user->hasPermission('permission-3')); + $this->assertFalse($this->user->hasPermission('non-existent-permission')); + } + + /** + * Test that the trait works with direct user permissions + */ + public function test_works_with_direct_user_permissions() + { + // Add permission directly to user + $this->user->permissions()->attach($this->permission->id); + + // Test that user has the direct permission + $this->assertTrue($this->user->hasPermission('test-permission')); + + // Test that loadPermissions still works + $this->user->loadPermissions(); + $this->assertTrue($this->user->hasPermission('test-permission')); + } + + /** + * Test that the trait works with both direct and group permissions + */ + public function test_works_with_both_direct_and_group_permissions() + { + // Create additional permission + $permission2 = Permission::factory()->create(['name' => 'permission-2']); + + // Add permission directly to user + $this->user->permissions()->attach($this->permission->id); + + // Add user to group with different permission + $this->user->groupMembersFromMemberable()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + $this->group->permissions()->attach($permission2->id); + + // Test that user has both permissions + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertTrue($this->user->hasPermission('permission-2')); + + // Test that loadPermissions works + $this->user->loadPermissions(); + $this->assertTrue($this->user->hasPermission('test-permission')); + $this->assertTrue($this->user->hasPermission('permission-2')); + } +} diff --git a/upgrades/2025_09_04_124102_populate_cases_started_and_participated_with_stages_and_progress.php b/upgrades/2025_09_04_124102_populate_cases_started_and_participated_with_stages_and_progress.php new file mode 100644 index 0000000000..eb2ad9ff99 --- /dev/null +++ b/upgrades/2025_09_04_124102_populate_cases_started_and_participated_with_stages_and_progress.php @@ -0,0 +1,158 @@ +updateStageAndProgressDataOptimized(); + $this->logTimeElapsed('Updated stage and progress data', $startTime); + + echo PHP_EOL; + } + + /** + * Log the time elapsed since the start of the process. + * + * @param string $message Message to log + * @param float $startTime Time when the processing started (in microseconds) + * @return void + */ + private function logTimeElapsed(string $message, float $startTime): void + { + $currentTime = microtime(true); + $timeElapsed = $currentTime - $startTime; + + // Format the elapsed time to 4 decimal places for higher precision + echo " {$message} - Time elapsed: " . number_format($timeElapsed, 4) . ' seconds' . PHP_EOL; + } + + /** + * Reverse the upgrade migration. + * + * @return void + */ + public function down() + { + DB::table('cases_started')->update([ + 'last_stage_id' => null, + 'last_stage_name' => null, + 'progress' => 0, + ]); + DB::table('cases_participated')->update([ + 'last_stage_id' => null, + 'last_stage_name' => null, + 'progress' => 0, + ]); + } + + /** + * Update the last_stage_name and progress fields using optimized batch processing + * + * @return void + */ + private function updateStageAndProgressDataOptimized() + { + // Define status mappings with their corresponding stage names and progress values + $statusMappings = [ + 'COMPLETED' => ['stage' => 'COMPLETED', 'progress' => 100], + 'IN_PROGRESS' => ['stage' => 'IN_PROGRESS', 'progress' => 50], + 'ACTIVE' => ['stage' => 'IN_PROGRESS', 'progress' => 50], + 'DRAFT' => ['stage' => 'DRAFT', 'progress' => 0], + 'ERROR' => ['stage' => 'ERROR', 'progress' => 0], + 'CANCELED' => ['stage' => 'CANCELED', 'progress' => 0], + 'PAUSED' => ['stage' => 'PAUSED', 'progress' => 0], + ]; + + // Process each status for cases_participated table + foreach ($statusMappings as $status => $mapping) { + $this->processBatchByStatus( + 'cases_participated', + $status, + $mapping['stage'], + $mapping['progress'], + "{$status} cases with progress {$mapping['progress']}%" + ); + } + + // Process each status for cases_started table + foreach ($statusMappings as $status => $mapping) { + $this->processBatchByStatus( + 'cases_started', + $status, + $mapping['stage'], + $mapping['progress'], + "{$status} cases with progress {$mapping['progress']}%" + ); + } + } + + /** + * Process cases by status in batches for better performance + * + * @param string $tableName + * @param string $status + * @param string $stageName + * @param int $progress + * @param string $description + * @return void + */ + private function processBatchByStatus(string $tableName, string $status, string $stageName, int $progress, string $description) + { + $offset = 0; + $totalProcessed = 0; + + do { + // Get batch of IDs to process - now we update ALL records with this status + // regardless of their current last_stage_name or progress values + $batchIds = DB::table($tableName) + ->where('case_status', $status) + ->whereNull('last_stage_name') + ->select('id') + ->offset($offset) + ->limit(self::BATCH_SIZE) + ->pluck('id') + ->toArray(); + + if (empty($batchIds)) { + break; + } + + // Update batch using raw SQL for better performance + $updated = DB::table($tableName) + ->whereIn('id', $batchIds) + ->update([ + 'last_stage_name' => $stageName, + 'progress' => $progress, + ]); + + $totalProcessed += $updated; + $offset += self::BATCH_SIZE; + + // Progress indicator for large tables + if ($totalProcessed % 50000 === 0) { + echo " Processed {$tableName} table {$totalProcessed} {$status} cases..." . PHP_EOL; + } + } while (count($batchIds) === self::BATCH_SIZE); + + if ($totalProcessed > 0) { + echo " Updated {$tableName} table {$totalProcessed} {$description}" . PHP_EOL; + } + } +}