diff --git a/ProcessMaker/Contracts/PermissionRepositoryInterface.php b/ProcessMaker/Contracts/PermissionRepositoryInterface.php index 8d0146dcef..9d3659563b 100644 --- a/ProcessMaker/Contracts/PermissionRepositoryInterface.php +++ b/ProcessMaker/Contracts/PermissionRepositoryInterface.php @@ -33,4 +33,9 @@ public function getGroupPermissionsById(int $groupId): array; * Get nested group permissions (recursive) */ public function getNestedGroupPermissions(int $groupId): array; + + /** + * Get all users affected by permissions inherited from the given group subtree. + */ + public function getAffectedUserIdsForGroup(int $groupId): array; } diff --git a/ProcessMaker/Http/Controllers/Auth/LoginController.php b/ProcessMaker/Http/Controllers/Auth/LoginController.php index 6be170ac5b..c6ee8cd3eb 100644 --- a/ProcessMaker/Http/Controllers/Auth/LoginController.php +++ b/ProcessMaker/Http/Controllers/Auth/LoginController.php @@ -16,6 +16,7 @@ use ProcessMaker\Models\Setting; use ProcessMaker\Models\User; use ProcessMaker\Package\Auth\Database\Seeds\AuthDefaultSeeder; +use ProcessMaker\Services\PermissionCacheService; use ProcessMaker\Traits\HasControllerAddons; class LoginController extends Controller @@ -262,7 +263,7 @@ public function beforeLogout(Request $request) //Clear the user permissions $userId = Auth::user()->id; - Cache::forget("user_{$userId}_permissions"); + app(PermissionCacheService::class)->forgetLegacyUserPermissions($userId); Cache::forget("user_{$userId}_project_assets"); // Clear the user session @@ -364,9 +365,13 @@ public function login(Request $request, User $user) return redirect()->route('password.change'); } // Cache user permissions for a day to improve performance - Cache::remember("user_{$user->id}_permissions", 86400, function () use ($user) { - return $user->permissions()->pluck('name')->toArray(); - }); + app(PermissionCacheService::class)->rememberLegacyUserPermissions( + $user->id, + 86400, + function () use ($user) { + return $user->permissions()->pluck('name')->toArray(); + } + ); $this->setupLanguage($request, $user); diff --git a/ProcessMaker/Http/Middleware/CustomAuthorize.php b/ProcessMaker/Http/Middleware/CustomAuthorize.php index 4652818270..a3d5000925 100644 --- a/ProcessMaker/Http/Middleware/CustomAuthorize.php +++ b/ProcessMaker/Http/Middleware/CustomAuthorize.php @@ -7,7 +7,6 @@ use Illuminate\Auth\Middleware\Authorize as Middleware; use Illuminate\Http\Request; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -16,6 +15,7 @@ use ProcessMaker\Models\Screen; use ProcessMaker\Models\Script; use ProcessMaker\Models\User; +use ProcessMaker\Services\PermissionCacheService; use ProcessMaker\Traits\ProjectAssetTrait; use Symfony\Component\HttpFoundation\Response; @@ -94,9 +94,13 @@ private function userHasAccessToProject($request, $userId, $models) private function getUserPermissions($user) { - return Cache::remember("user_{$user->id}_permissions", 86400, function () use ($user) { - return $user->permissions()->pluck('name')->toArray(); - }); + return app(PermissionCacheService::class)->rememberLegacyUserPermissions( + $user->id, + 86400, + function () use ($user) { + return $user->permissions()->pluck('name')->toArray(); + } + ); } private function hasPermission($userPermissions, $permission) diff --git a/ProcessMaker/Http/Middleware/IsManager.php b/ProcessMaker/Http/Middleware/IsManager.php index f43580842a..561912941d 100644 --- a/ProcessMaker/Http/Middleware/IsManager.php +++ b/ProcessMaker/Http/Middleware/IsManager.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use ProcessMaker\Services\PermissionCacheService; use Symfony\Component\HttpFoundation\Response; class IsManager @@ -76,12 +77,11 @@ private function simulateRequiredPermissionsForRequest($user, array $requiredPer } // 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 + app(PermissionCacheService::class)->putLegacyUserPermissions($user->id, $simulatedPermissions, 5); } catch (\Exception $e) { Log::error('IsManager middleware - Error simulating permissions: ' . $e->getMessage()); } @@ -93,10 +93,8 @@ private function simulateRequiredPermissionsForRequest($user, array $requiredPer private function cleanupSimulatedPermission($user) { try { - $cacheKey = "user_{$user->id}_permissions"; - // delete the cache to force the reload of real permissions - Cache::forget($cacheKey); + app(PermissionCacheService::class)->forgetLegacyUserPermissions($user->id); } catch (\Exception $e) { Log::error('IsManager middleware - Error cleaning up simulated permissions: ' . $e->getMessage()); } diff --git a/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php b/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php index 87a76e8954..15db749631 100644 --- a/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php +++ b/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChange.php @@ -4,8 +4,6 @@ use Illuminate\Support\Facades\Log; use ProcessMaker\Events\GroupMembershipChanged; -use ProcessMaker\Models\Group; -use ProcessMaker\Models\User; use ProcessMaker\Services\PermissionServiceManager; class InvalidatePermissionCacheOnGroupHierarchyChange @@ -28,9 +26,13 @@ public function handle(GroupMembershipChanged $event): void // 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(); + if ($group) { + $this->permissionService->invalidateAffectedCachesForGroup((int) $group->id); + } - Log::info("Successfully invalidated permission cache for group hierarchy change: {$action} for group {$group->id}"); + Log::info( + "Successfully invalidated permission cache for group hierarchy change: {$action} for group " . ($group?->id ?? 'unknown') + ); } catch (\Exception $e) { Log::error('Failed to invalidate permission cache on group hierarchy change', [ 'error' => $e->getMessage(), diff --git a/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php b/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php index cffd42af6a..47df504904 100644 --- a/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php +++ b/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdate.php @@ -28,7 +28,7 @@ public function handle(PermissionUpdated $event): void // Invalidate cache for group if group permissions were updated if ($event->getGroupId()) { - $this->permissionService->invalidateAll(); + $this->permissionService->invalidateAffectedCachesForGroup((int) $event->getGroupId()); } } catch (\Exception $e) { Log::error('Failed to invalidate permission cache', [ diff --git a/ProcessMaker/Models/User.php b/ProcessMaker/Models/User.php index 3eb46dfc3a..c13c133659 100644 --- a/ProcessMaker/Models/User.php +++ b/ProcessMaker/Models/User.php @@ -591,7 +591,7 @@ public function refresh() // Clear permissions and user_permissions Cache::forget('permissions'); - Cache::forget("user_{$this->id}_permissions"); + $this->invalidatePermissionCache(); // return the refreshed user instance return $this; diff --git a/ProcessMaker/Repositories/PermissionRepository.php b/ProcessMaker/Repositories/PermissionRepository.php index 694bece228..76673a1d64 100644 --- a/ProcessMaker/Repositories/PermissionRepository.php +++ b/ProcessMaker/Repositories/PermissionRepository.php @@ -183,6 +183,19 @@ public function getNestedGroupPermissions(int $groupId): array return array_unique($permissions); } + /** + * Get all user ids affected by permissions inherited from the given group subtree. + */ + public function getAffectedUserIdsForGroup(int $groupId): array + { + $group = Group::find($groupId); + if (!$group) { + return []; + } + + return $this->collectAffectedUserIds($group); + } + /** * Get nested group permissions recursively with protection against infinite loops */ @@ -261,4 +274,37 @@ private function hasNestedGroupPermission(Group $group, string $permission, arra return false; } + + /** + * Collect all users inside a group subtree with protection against cycles. + */ + private function collectAffectedUserIds(Group $group, array $visitedGroups = [], int $maxDepth = 25): array + { + if (in_array($group->id, $visitedGroups, true) || count($visitedGroups) >= $maxDepth) { + return []; + } + + if (!$group->relationLoaded('groupMembers')) { + $group->load('groupMembers.member'); + } + + $visitedGroups[] = $group->id; + $userIds = []; + + foreach ($group->groupMembers as $groupMember) { + if ($groupMember->member_type === User::class) { + $userIds[] = (int) $groupMember->member_id; + continue; + } + + if ($groupMember->member_type === Group::class && $groupMember->member instanceof Group) { + $userIds = array_merge( + $userIds, + $this->collectAffectedUserIds($groupMember->member, $visitedGroups, $maxDepth) + ); + } + } + + return array_values(array_unique($userIds)); + } } diff --git a/ProcessMaker/Services/PermissionCacheService.php b/ProcessMaker/Services/PermissionCacheService.php index 167f7be0e8..36f03adba0 100644 --- a/ProcessMaker/Services/PermissionCacheService.php +++ b/ProcessMaker/Services/PermissionCacheService.php @@ -16,6 +16,16 @@ class PermissionCacheService implements PermissionCacheInterface private const GROUP_PERMISSIONS_KEY = 'group_permissions'; + private const LEGACY_USER_PERMISSIONS_KEY = 'user'; + + private const TRACKED_PERMISSION_KEYS = 'permission_cache_keys'; + + private const TRACKED_PERMISSION_KEYS_LOCK = 'permission_cache_keys_lock'; + + private const TRACKED_PERMISSION_KEYS_LOCK_SECONDS = 10; + + private const TRACKED_PERMISSION_KEYS_LOCK_WAIT_SECONDS = 5; + /** * Get cached permissions for a user */ @@ -41,6 +51,7 @@ public function cacheUserPermissions(int $userId, array $permissions): void try { Cache::put($key, $permissions, self::USER_PERMISSIONS_TTL); + $this->trackPermissionKey($key); } catch (\Exception $e) { Log::warning("Failed to cache user permissions for user {$userId}: " . $e->getMessage()); } @@ -71,6 +82,7 @@ public function cacheGroupPermissions(int $groupId, array $permissions): void try { Cache::put($key, $permissions, self::GROUP_PERMISSIONS_TTL); + $this->trackPermissionKey($key); } catch (\Exception $e) { Log::warning("Failed to cache group permissions for group {$groupId}: " . $e->getMessage()); } @@ -81,15 +93,72 @@ public function cacheGroupPermissions(int $groupId, array $permissions): void */ public function invalidateUserPermissions(int $userId): void { - $key = $this->getUserPermissionsKey($userId); + $keys = [ + $this->getUserPermissionsKey($userId), + $this->getLegacyUserPermissionsKey($userId), + ]; try { - Cache::forget($key); + foreach ($keys as $key) { + Cache::forget($key); + $this->untrackPermissionKey($key); + } } catch (\Exception $e) { Log::warning("Failed to invalidate user permissions cache for user {$userId}: " . $e->getMessage()); } } + /** + * Remember legacy user permissions and track the key for scoped clearAll(). + */ + public function rememberLegacyUserPermissions(int $userId, int $ttl, callable $callback): array + { + $key = $this->getLegacyUserPermissionsKey($userId); + + try { + $permissions = Cache::remember($key, $ttl, $callback); + $this->trackPermissionKey($key); + + return is_array($permissions) ? $permissions : []; + } catch (\Exception $e) { + Log::warning("Failed to remember legacy user permissions for user {$userId}: " . $e->getMessage()); + + $permissions = $callback(); + + return is_array($permissions) ? $permissions : []; + } + } + + /** + * Cache legacy user permissions and track the key for scoped clearAll(). + */ + public function putLegacyUserPermissions(int $userId, array $permissions, int $ttl): void + { + $key = $this->getLegacyUserPermissionsKey($userId); + + try { + Cache::put($key, $permissions, $ttl); + $this->trackPermissionKey($key); + } catch (\Exception $e) { + Log::warning("Failed to cache legacy user permissions for user {$userId}: " . $e->getMessage()); + } + } + + /** + * Forget the legacy user permission cache and remove it from the tracked index. + */ + public function forgetLegacyUserPermissions(int $userId): void + { + $key = $this->getLegacyUserPermissionsKey($userId); + + try { + Cache::forget($key); + $this->untrackPermissionKey($key); + } catch (\Exception $e) { + Log::warning("Failed to forget legacy user permissions for user {$userId}: " . $e->getMessage()); + } + } + /** * Invalidate group permissions cache */ @@ -99,6 +168,7 @@ public function invalidateGroupPermissions(int $groupId): void try { Cache::forget($key); + $this->untrackPermissionKey($key); } catch (\Exception $e) { Log::warning("Failed to invalidate group permissions cache for group {$groupId}: " . $e->getMessage()); } @@ -110,8 +180,13 @@ public function invalidateGroupPermissions(int $groupId): void public function clearAll(): void { try { - // Clear all permission-related caches - Cache::flush(); + $this->withTrackedPermissionKeysLock(function (): void { + foreach ($this->getTrackedPermissionKeysFromCache() as $key) { + Cache::forget($key); + } + + Cache::forget(self::TRACKED_PERMISSION_KEYS); + }); } catch (\Exception $e) { Log::warning('Failed to clear all permission caches: ' . $e->getMessage()); } @@ -133,6 +208,14 @@ private function getGroupPermissionsKey(int $groupId): string return self::GROUP_PERMISSIONS_KEY . ":{$groupId}"; } + /** + * Get cache key for legacy user permissions. + */ + private function getLegacyUserPermissionsKey(int $userId): string + { + return self::LEGACY_USER_PERMISSIONS_KEY . "_{$userId}_permissions"; + } + /** * Warm up cache for a user */ @@ -160,4 +243,61 @@ public function getCacheStats(): array 'cache_driver' => config('cache.default'), ]; } + + /** + * Track service-managed permission keys so clearAll() can stay scoped. + */ + private function trackPermissionKey(string $key): void + { + $this->withTrackedPermissionKeysLock(function () use ($key): void { + $keys = $this->getTrackedPermissionKeysFromCache(); + + if (!in_array($key, $keys, true)) { + $keys[] = $key; + Cache::forever(self::TRACKED_PERMISSION_KEYS, $keys); + } + }); + } + + /** + * Stop tracking a permission key after explicit invalidation. + */ + private function untrackPermissionKey(string $key): void + { + $this->withTrackedPermissionKeysLock(function () use ($key): void { + $keys = array_values(array_filter( + $this->getTrackedPermissionKeysFromCache(), + fn ($trackedKey) => $trackedKey !== $key + )); + + if (empty($keys)) { + Cache::forget(self::TRACKED_PERMISSION_KEYS); + + return; + } + + Cache::forever(self::TRACKED_PERMISSION_KEYS, $keys); + }); + } + + /** + * Read the tracked permission keys index without locking. + */ + private function getTrackedPermissionKeysFromCache(): array + { + $keys = Cache::get(self::TRACKED_PERMISSION_KEYS, []); + + return is_array($keys) ? $keys : []; + } + + /** + * Serialize tracked-key mutations so concurrent warmups do not drop entries. + */ + private function withTrackedPermissionKeysLock(callable $callback): mixed + { + return Cache::lock( + self::TRACKED_PERMISSION_KEYS_LOCK, + self::TRACKED_PERMISSION_KEYS_LOCK_SECONDS + )->block(self::TRACKED_PERMISSION_KEYS_LOCK_WAIT_SECONDS, $callback); + } } diff --git a/ProcessMaker/Services/PermissionServiceManager.php b/ProcessMaker/Services/PermissionServiceManager.php index 9d5b53d6fb..53bf8d0d4d 100644 --- a/ProcessMaker/Services/PermissionServiceManager.php +++ b/ProcessMaker/Services/PermissionServiceManager.php @@ -106,6 +106,43 @@ public function invalidateUserCache(int $userId): void Log::info("Invalidated permission cache for user {$userId}"); } + /** + * Invalidate caches for multiple users. + */ + public function invalidateUserCaches(array $userIds): void + { + $userIds = array_values(array_unique(array_map('intval', $userIds))); + + foreach ($userIds as $userId) { + if ($userId <= 0) { + continue; + } + + $this->cacheService->invalidateUserPermissions($userId); + } + + Log::info('Invalidated permission cache for affected users', [ + 'user_count' => count($userIds), + 'user_ids' => $userIds, + ]); + } + + /** + * Invalidate the caches affected by a group permission change. + */ + public function invalidateAffectedCachesForGroup(int $groupId): void + { + $this->cacheService->invalidateGroupPermissions($groupId); + + $userIds = $this->repository->getAffectedUserIdsForGroup($groupId); + $this->invalidateUserCaches($userIds); + + Log::info("Invalidated permission cache for group {$groupId}", [ + 'affected_user_count' => count($userIds), + 'affected_user_ids' => $userIds, + ]); + } + /** * Invalidate all permission caches */ diff --git a/ProcessMaker/Traits/HasAuthorization.php b/ProcessMaker/Traits/HasAuthorization.php index 591102afa2..2314b7e8cd 100644 --- a/ProcessMaker/Traits/HasAuthorization.php +++ b/ProcessMaker/Traits/HasAuthorization.php @@ -2,10 +2,10 @@ namespace ProcessMaker\Traits; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use ProcessMaker\Models\Group; use ProcessMaker\Models\Permission; +use ProcessMaker\Services\PermissionCacheService; use ProcessMaker\Services\PermissionServiceManager; trait HasAuthorization @@ -33,9 +33,13 @@ public function loadPermissions() public function loadUserPermissions() { $user = $this; - $permissions = Cache::remember("user_{$user->id}_permissions", 86400, function () use ($user) { - return $user->permissions()->pluck('name')->toArray(); - }); + $permissions = app(PermissionCacheService::class)->rememberLegacyUserPermissions( + $user->id, + 86400, + function () use ($user) { + return $user->permissions()->pluck('name')->toArray(); + } + ); return $this->addCategoryViewPermissions($permissions); } diff --git a/tests/Feature/PermissionCacheInvalidationTest.php b/tests/Feature/PermissionCacheInvalidationTest.php index c27e9bfcc9..49766f5ffd 100644 --- a/tests/Feature/PermissionCacheInvalidationTest.php +++ b/tests/Feature/PermissionCacheInvalidationTest.php @@ -4,6 +4,11 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Redis; +use Laravel\Passport\Passport; +use ProcessMaker\Models\Group; +use ProcessMaker\Models\GroupMember; use ProcessMaker\Models\Permission; use ProcessMaker\Models\User; use ProcessMaker\Services\PermissionServiceManager; @@ -23,7 +28,7 @@ protected function setUp(): void // Ensure the user is created by the trait if (!$this->user) { $this->user = User::factory()->create([ - 'password' => \Illuminate\Support\Facades\Hash::make('password'), + 'password' => Hash::make('password'), 'is_administrator' => true, ]); } @@ -114,4 +119,65 @@ public function test_permission_cache_is_invalidated_when_user_permissions_remov $this->assertContains('permission-1', $freshPermissions); $this->assertNotContains('permission-2', $freshPermissions); } + + public function test_group_permission_update_does_not_logout_redis_backed_session() + { + $this->ensureRedisSessionAndCacheAreAvailable(); + + $originalPermission = Permission::factory()->create(['name' => 'redis-group-permission']); + Permission::factory()->create(['name' => 'redis-group-permission-updated']); + $group = Group::factory()->create(['name' => 'Redis Permission Group']); + $affectedUser = User::factory()->create([ + 'password' => Hash::make('password'), + 'is_administrator' => false, + ]); + + GroupMember::factory()->create([ + 'group_id' => $group->id, + 'member_type' => User::class, + 'member_id' => $affectedUser->id, + ]); + + $group->permissions()->sync([$originalPermission->id]); + + $this->permissionService->warmUpUserCache($affectedUser->id); + + $cachedPermissions = Cache::get("user_permissions:{$affectedUser->id}"); + $this->assertNotNull($cachedPermissions); + $this->assertContains('redis-group-permission', $cachedPermissions); + + $this->actingAs($this->user, 'web') + ->post(route('keep-alive')) + ->assertNoContent(); + + Passport::actingAs($this->user); + + $this->json('PUT', '/api/1.0/permissions', [ + 'group_id' => $group->id, + 'permission_names' => ['redis-group-permission-updated'], + ])->assertNoContent(); + + $this->post(route('keep-alive'))->assertNoContent(); + $this->assertAuthenticatedAs($this->user, 'web'); + + $freshPermissions = $this->permissionService->getUserPermissions($affectedUser->id); + $this->assertContains('redis-group-permission-updated', $freshPermissions); + $this->assertNotContains('redis-group-permission', $freshPermissions); + } + + private function ensureRedisSessionAndCacheAreAvailable(): void + { + config()->set('cache.default', 'redis'); + config()->set('session.driver', 'redis'); + config()->set('session.connection', 'default'); + + try { + Redis::connection('default')->ping(); + Redis::connection('cache')->ping(); + } catch (\Throwable $e) { + $this->markTestSkipped( + 'Redis is not available for the permission cache invalidation regression test: ' . $e->getMessage() + ); + } + } } diff --git a/tests/Model/UserTest.php b/tests/Model/UserTest.php index 0afa3e146d..80abc45ba0 100644 --- a/tests/Model/UserTest.php +++ b/tests/Model/UserTest.php @@ -130,4 +130,19 @@ public function testAddCategoryViewPermissions() $testFor('screen', 'screens'); $testFor('script', 'scripts'); } + + public function testRefreshInvalidatesBothPermissionCacheFamilies() + { + $user = User::factory()->create(['password' => Hash::make('password')]); + + Cache::put("user_permissions:{$user->id}", ['cached'], 3600); + Cache::put("user_{$user->id}_permissions", ['legacy'], 3600); + Cache::put('unrelated-cache-key', 'keep-me', 3600); + + $user->refresh(); + + $this->assertNull(Cache::get("user_permissions:{$user->id}")); + $this->assertNull(Cache::get("user_{$user->id}_permissions")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } } diff --git a/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php index 40ade6a6a0..8756aec563 100644 --- a/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php +++ b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnGroupHierarchyChangeTest.php @@ -174,4 +174,46 @@ public function test_handles_events_with_null_parent_group() $this->expectNotToPerformAssertions(); $this->listener->handle($event); } + + public function test_invalidates_only_affected_users_and_preserves_unrelated_cache_entries() + { + $descendantGroup = Group::factory()->create(['name' => 'Descendant Group']); + $unrelatedGroup = Group::factory()->create(['name' => 'Unrelated Group']); + $descendantUser = User::factory()->create(['username' => 'descendant-user']); + $unrelatedUser = User::factory()->create(['username' => 'unrelated-user']); + + $this->group->groupMembers()->create([ + 'group_id' => $this->group->id, + 'member_id' => $descendantGroup->id, + 'member_type' => Group::class, + ]); + + $descendantGroup->groupMembers()->create([ + 'group_id' => $descendantGroup->id, + 'member_id' => $descendantUser->id, + 'member_type' => User::class, + ]); + + $unrelatedGroup->groupMembers()->create([ + 'group_id' => $unrelatedGroup->id, + 'member_id' => $unrelatedUser->id, + 'member_type' => User::class, + ]); + + app(\ProcessMaker\Services\PermissionServiceManager::class)->warmUpUserCache($descendantUser->id); + app(\ProcessMaker\Services\PermissionServiceManager::class)->warmUpUserCache($unrelatedUser->id); + + Cache::put("user_{$descendantUser->id}_permissions", ['legacy'], 3600); + Cache::put("user_{$unrelatedUser->id}_permissions", ['legacy'], 3600); + Cache::put('unrelated-cache-key', 'keep-me', 3600); + + $event = new GroupMembershipChanged($this->group, $this->parentGroup, 'updated'); + $this->listener->handle($event); + + $this->assertNull(Cache::get("user_permissions:{$descendantUser->id}")); + $this->assertNull(Cache::get("user_{$descendantUser->id}_permissions")); + $this->assertNotNull(Cache::get("user_permissions:{$unrelatedUser->id}")); + $this->assertNotNull(Cache::get("user_{$unrelatedUser->id}_permissions")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } } diff --git a/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdateTest.php b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdateTest.php new file mode 100644 index 0000000000..28e70c8bc8 --- /dev/null +++ b/tests/unit/ProcessMaker/Listeners/InvalidatePermissionCacheOnUpdateTest.php @@ -0,0 +1,121 @@ +listener = new InvalidatePermissionCacheOnUpdate( + app(\ProcessMaker\Services\PermissionServiceManager::class) + ); + + Cache::flush(); + } + + public function test_invalidates_only_affected_group_users_and_preserves_unrelated_cache_entries() + { + $permission = Permission::factory()->create(['name' => 'test-permission']); + $group = Group::factory()->create(['name' => 'Target Group']); + $childGroup = Group::factory()->create(['name' => 'Child Group']); + $unrelatedGroup = Group::factory()->create(['name' => 'Unrelated Group']); + $directUser = User::factory()->create(['username' => 'direct-user']); + $childUser = User::factory()->create(['username' => 'child-user']); + $unrelatedUser = User::factory()->create(['username' => 'unrelated-user']); + + $group->permissions()->attach($permission->id); + + $group->groupMembers()->create([ + 'group_id' => $group->id, + 'member_id' => $directUser->id, + 'member_type' => User::class, + ]); + + $group->groupMembers()->create([ + 'group_id' => $group->id, + 'member_id' => $childGroup->id, + 'member_type' => Group::class, + ]); + + $childGroup->groupMembers()->create([ + 'group_id' => $childGroup->id, + 'member_id' => $childUser->id, + 'member_type' => User::class, + ]); + + $unrelatedGroup->groupMembers()->create([ + 'group_id' => $unrelatedGroup->id, + 'member_id' => $unrelatedUser->id, + 'member_type' => User::class, + ]); + + $permissionService = app(\ProcessMaker\Services\PermissionServiceManager::class); + $permissionService->warmUpUserCache($directUser->id); + $permissionService->warmUpUserCache($childUser->id); + $permissionService->warmUpUserCache($unrelatedUser->id); + + Cache::put("user_{$directUser->id}_permissions", ['legacy'], 3600); + Cache::put("user_{$childUser->id}_permissions", ['legacy'], 3600); + Cache::put("user_{$unrelatedUser->id}_permissions", ['legacy'], 3600); + Cache::put("group_permissions:{$group->id}", ['group-permission'], 3600); + Cache::put('unrelated-cache-key', 'keep-me', 3600); + + $event = new PermissionUpdated( + ['test-permission'], + [], + false, + null, + (string) $group->id + ); + + $this->listener->handle($event); + + $this->assertNull(Cache::get("user_permissions:{$directUser->id}")); + $this->assertNull(Cache::get("user_permissions:{$childUser->id}")); + $this->assertNull(Cache::get("user_{$directUser->id}_permissions")); + $this->assertNull(Cache::get("user_{$childUser->id}_permissions")); + $this->assertNull(Cache::get("group_permissions:{$group->id}")); + + $this->assertNotNull(Cache::get("user_permissions:{$unrelatedUser->id}")); + $this->assertNotNull(Cache::get("user_{$unrelatedUser->id}_permissions")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } + + public function test_invalidates_legacy_and_new_cache_for_user_updates() + { + $user = User::factory()->create(['username' => 'target-user']); + + Cache::put("user_permissions:{$user->id}", ['new-cache'], 3600); + Cache::put("user_{$user->id}_permissions", ['legacy-cache'], 3600); + Cache::put('unrelated-cache-key', 'keep-me', 3600); + + $event = new PermissionUpdated( + ['edit-users'], + [], + false, + (string) $user->id, + null + ); + + $this->listener->handle($event); + + $this->assertNull(Cache::get("user_permissions:{$user->id}")); + $this->assertNull(Cache::get("user_{$user->id}_permissions")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } +} diff --git a/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php b/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php index 46e83aa3af..cb26cda6da 100644 --- a/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php +++ b/tests/unit/ProcessMaker/Services/PermissionCacheServiceTest.php @@ -126,15 +126,18 @@ public function test_invalidate_user_permissions_clears_cache_correctly() { // Cache user permissions $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->putLegacyUserPermissions($this->userId, $this->userPermissions, 3600); // Verify cache exists $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("user_{$this->userId}_permissions")); // Invalidate cache $this->cacheService->invalidateUserPermissions($this->userId); // Verify cache was cleared $this->assertNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNull(Cache::get("user_{$this->userId}_permissions")); } /** @@ -162,10 +165,13 @@ public function test_clear_all_clears_all_permission_caches() { // Cache both user and group permissions $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->putLegacyUserPermissions($this->userId, $this->userPermissions, 3600); $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + Cache::put('unrelated-cache-key', 'keep-me', 3600); // Verify both caches exist $this->assertNotNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNotNull(Cache::get("user_{$this->userId}_permissions")); $this->assertNotNull(Cache::get("group_permissions:{$this->groupId}")); // Clear all caches @@ -173,7 +179,64 @@ public function test_clear_all_clears_all_permission_caches() // Verify both caches were cleared $this->assertNull(Cache::get("user_permissions:{$this->userId}")); + $this->assertNull(Cache::get("user_{$this->userId}_permissions")); $this->assertNull(Cache::get("group_permissions:{$this->groupId}")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } + + /** + * Test that clearAll clears tracked legacy permission caches without touching unrelated cache. + */ + public function test_clear_all_clears_legacy_user_permission_cache_only_when_tracked() + { + $permissions = $this->cacheService->rememberLegacyUserPermissions($this->userId, 3600, function () { + return $this->userPermissions; + }); + Cache::put('unrelated-cache-key', 'keep-me', 3600); + + $this->assertEquals($this->userPermissions, $permissions); + $this->assertNotNull(Cache::get("user_{$this->userId}_permissions")); + + $this->cacheService->clearAll(); + + $this->assertNull(Cache::get("user_{$this->userId}_permissions")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } + + /** + * Test that tracked permission keys include every managed cache entry. + */ + public function test_tracked_permission_keys_include_all_managed_cache_entries() + { + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->putLegacyUserPermissions($this->userId, $this->userPermissions, 3600); + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + $trackedKeys = Cache::get('permission_cache_keys'); + + $this->assertIsArray($trackedKeys); + $this->assertContains("user_permissions:{$this->userId}", $trackedKeys); + $this->assertContains("user_{$this->userId}_permissions", $trackedKeys); + $this->assertContains("group_permissions:{$this->groupId}", $trackedKeys); + } + + /** + * Test that tracked permission keys are pruned when a user cache is invalidated. + */ + public function test_invalidate_user_permissions_removes_only_user_keys_from_tracked_index() + { + $this->cacheService->cacheUserPermissions($this->userId, $this->userPermissions); + $this->cacheService->putLegacyUserPermissions($this->userId, $this->userPermissions, 3600); + $this->cacheService->cacheGroupPermissions($this->groupId, $this->groupPermissions); + + $this->cacheService->invalidateUserPermissions($this->userId); + + $trackedKeys = Cache::get('permission_cache_keys'); + + $this->assertIsArray($trackedKeys); + $this->assertNotContains("user_permissions:{$this->userId}", $trackedKeys); + $this->assertNotContains("user_{$this->userId}_permissions", $trackedKeys); + $this->assertContains("group_permissions:{$this->groupId}", $trackedKeys); } /** diff --git a/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php b/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php index f62c4eb626..6672078ef0 100644 --- a/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php +++ b/tests/unit/ProcessMaker/Services/PermissionServiceManagerTest.php @@ -257,4 +257,58 @@ public function test_handles_users_with_no_permissions_gracefully() $this->assertIsArray($cachedPermissions); $this->assertEmpty($cachedPermissions); } + + public function test_invalidate_affected_caches_for_group_clears_only_affected_users_and_group_cache() + { + $childGroup = Group::factory()->create(['name' => 'Child Group']); + $unrelatedGroup = Group::factory()->create(['name' => 'Unrelated Group']); + $userInChild = User::factory()->create(['username' => 'child-user']); + $unrelatedUser = User::factory()->create(['username' => 'unrelated-user']); + + $this->group->groupMembers()->create([ + 'group_id' => $this->group->id, + 'member_id' => $this->user->id, + 'member_type' => User::class, + ]); + + $this->group->groupMembers()->create([ + 'group_id' => $this->group->id, + 'member_id' => $childGroup->id, + 'member_type' => Group::class, + ]); + + $childGroup->groupMembers()->create([ + 'group_id' => $childGroup->id, + 'member_id' => $userInChild->id, + 'member_type' => User::class, + ]); + + $unrelatedGroup->groupMembers()->create([ + 'group_id' => $unrelatedGroup->id, + 'member_id' => $unrelatedUser->id, + 'member_type' => User::class, + ]); + + $this->serviceManager->warmUpUserCache($this->user->id); + $this->serviceManager->warmUpUserCache($userInChild->id); + $this->serviceManager->warmUpUserCache($unrelatedUser->id); + + Cache::put("user_{$this->user->id}_permissions", ['legacy'], 3600); + Cache::put("user_{$userInChild->id}_permissions", ['legacy'], 3600); + Cache::put("user_{$unrelatedUser->id}_permissions", ['legacy'], 3600); + Cache::put("group_permissions:{$this->group->id}", ['group-permission'], 3600); + Cache::put('unrelated-cache-key', 'keep-me', 3600); + + $this->serviceManager->invalidateAffectedCachesForGroup($this->group->id); + + $this->assertNull(Cache::get("user_permissions:{$this->user->id}")); + $this->assertNull(Cache::get("user_permissions:{$userInChild->id}")); + $this->assertNull(Cache::get("user_{$this->user->id}_permissions")); + $this->assertNull(Cache::get("user_{$userInChild->id}_permissions")); + $this->assertNull(Cache::get("group_permissions:{$this->group->id}")); + + $this->assertNotNull(Cache::get("user_permissions:{$unrelatedUser->id}")); + $this->assertNotNull(Cache::get("user_{$unrelatedUser->id}_permissions")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } } diff --git a/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php b/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php index c32432b523..ca3a51aab0 100644 --- a/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php +++ b/tests/unit/ProcessMaker/Traits/HasAuthorizationTest.php @@ -3,9 +3,11 @@ namespace Tests\Unit\ProcessMaker\Traits; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use ProcessMaker\Models\Group; use ProcessMaker\Models\Permission; use ProcessMaker\Models\User; +use ProcessMaker\Services\PermissionCacheService; use Tests\TestCase; class HasAuthorizationTest extends TestCase @@ -66,6 +68,25 @@ public function test_load_permissions_works_correctly() $this->assertTrue($this->user->hasPermission('test-permission')); } + /** + * Test that legacy permission caches written by the trait are tracked for clearAll(). + */ + public function test_load_user_permissions_registers_legacy_cache_for_clear_all() + { + $this->user->permissions()->attach($this->permission->id); + Cache::put('unrelated-cache-key', 'keep-me', 3600); + + $permissions = $this->user->loadUserPermissions(); + + $this->assertContains('test-permission', $permissions); + $this->assertNotNull(Cache::get("user_{$this->user->id}_permissions")); + + app(PermissionCacheService::class)->clearAll(); + + $this->assertNull(Cache::get("user_{$this->user->id}_permissions")); + $this->assertSame('keep-me', Cache::get('unrelated-cache-key')); + } + /** * Test that invalidatePermissionCache works correctly */