Revert "Fix checkRoleEscalation performance and bugs in access checking" - #13881
Revert "Fix checkRoleEscalation performance and bugs in access checking"#13881RosiKyu wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR reverts prior changes from #12973 related to role escalation checks and API access checker behavior, removing cache-refresh hooks and simplifying the role escalation verification path back to per-command access checks.
Changes:
- Reverts
checkRoleEscalationto validate privileges by iteratingapiNameListand invokingcheckApiAccessper command. - Removes cache refresh/invalidation plumbing tied to role-permission mutations (and related API surface changes in
AccountManager/APIChecker). - Removes unit tests that specifically covered
checkRoleEscalation, account-level access checks, and cache behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| server/src/test/java/com/cloud/user/AccountManagerImplTest.java | Removes dedicated checkRoleEscalation unit tests. |
| server/src/main/java/org/apache/cloudstack/acl/RoleManagerImpl.java | Removes cache-refresh calls on role permission create/delete. |
| server/src/main/java/com/cloud/user/AccountManagerImpl.java | Reverts role escalation logic to per-API checkApiAccess; removes ACL-checker-specific helpers and refresh method. |
| server/src/main/java/com/cloud/user/AccountManager.java | Removes the refreshRoleCheckersCacheOnPermissionsChange API method. |
| plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java | Updates mock to match removed AccountManager method. |
| plugins/acl/static-role-based/src/main/java/org/apache/cloudstack/acl/StaticRoleBasedAPIAccessChecker.java | Removes account-level API list filtering method. |
| plugins/acl/project-role-based/src/main/java/org/apache/cloudstack/acl/ProjectRoleBasedApiAccessChecker.java | Removes account-level API list filtering method. |
| plugins/acl/dynamic-role-based/src/test/java/org/apache/cloudstack/acl/DynamicRoleBasedAPIAccessCheckerTest.java | Removes tests for account-level checks and cache behavior. |
| plugins/acl/dynamic-role-based/src/main/java/org/apache/cloudstack/acl/DynamicRoleBasedAPIAccessChecker.java | Stops using cached permissions list for account-level checkAccess (DB fetch per call). |
| api/src/main/java/org/apache/cloudstack/acl/APIChecker.java | Removes default helper methods (account API filtering, cache refresh hook, logger). |
Suppressed comments (1)
plugins/acl/dynamic-role-based/src/main/java/org/apache/cloudstack/acl/DynamicRoleBasedAPIAccessChecker.java:180
- checkAccess(Account, ...) fetches role permissions from RoleService on every call, even when cachePeriod > 0 and getRolePermissionsUsingCache(...) already provides a cached permission list. This negates the dynamic API checker cache for account-level checks and can trigger repeated DB reads when iterating many APIs (e.g., during role escalation checks).
List<RolePermission> allPermissions = roleService.findAllPermissionsBy(accountRole.getId());
if (checkApiPermissionByRole(accountRole, commandName, allPermissions)) {
return true;
}
throw new UnavailableCommandException(String.format("The API [%s] does not exist or is not available for the account %s.", commandName, account));
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| checkApiAccess(apiCheckers, caller, command); | ||
| } catch (PermissionDeniedException pde) { | ||
| String msg = String.format("User of Account %s and domain %s can not create an account with access to more privileges they have themself.", | ||
| caller, _domainMgr.getDomain(caller.getDomainId())); | ||
| logger.warn(msg); | ||
| throw new PermissionDeniedException(msg,pde); | ||
| } |
| if (findRolePermissionByRoleIdAndRule(role.getId(), rule.toString()) != null) { | ||
| throw new PermissionDeniedException("Rule already exists for the role: " + role.getName()); | ||
| } | ||
|
|
||
| accountManager.refreshRoleCheckersCacheOnPermissionsChange(role); | ||
|
|
||
| return Transaction.execute(new TransactionCallback<RolePermissionVO>() { |
| Mockito.lenient().doThrow(PermissionDeniedException.class).when(accountManagerImpl).checkRoleEscalation(callingAccount, accountMock); | ||
|
|
||
| accountManagerImpl.checkCallerApiPermissionsForUserOrAccountOperations(accountMock); | ||
| } | ||
|
|
||
| // --- Tests for checkRoleEscalation --- | ||
|
|
||
| private void setPrivateField(Object target, String fieldName, Object value) throws Exception { | ||
| Class<?> clazz = target.getClass(); | ||
| while (clazz != null) { | ||
| try { | ||
| java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); | ||
| field.setAccessible(true); | ||
| field.set(target, value); | ||
| return; | ||
| } catch (NoSuchFieldException e) { | ||
| clazz = clazz.getSuperclass(); | ||
| } | ||
| } | ||
| throw new NoSuchFieldException(fieldName); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCheckRoleEscalationSamePermissionsShouldPass() throws Exception { | ||
| APIChecker checker = Mockito.mock(APIChecker.class); | ||
| List<String> apis = Arrays.asList("api1", "api2", "api3"); | ||
| Mockito.when(checker.isEnabled()).thenReturn(true); | ||
|
|
||
| Account caller = Mockito.mock(Account.class); | ||
| Account requested = Mockito.mock(Account.class); | ||
|
|
||
| accountManagerImpl.setApiAccessCheckers(Arrays.asList(checker)); | ||
| setPrivateField(accountManagerImpl, "apiNameList", new ArrayList<>(apis)); | ||
|
|
||
| accountManagerImpl.checkRoleEscalation(caller, requested); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCheckRoleEscalationCallerHasMorePermissionsShouldPass() throws Exception { | ||
| List<String> allApis = Arrays.asList("api1", "api2", "api3"); | ||
| List<String> requestedApis = Arrays.asList("api1", "api2"); | ||
|
|
||
| APIAclChecker checker = Mockito.mock(APIAclChecker.class); | ||
| Mockito.when(checker.isEnabled()).thenReturn(true); | ||
|
|
||
| Account caller = Mockito.mock(Account.class); | ||
| Account requested = Mockito.mock(Account.class); | ||
|
|
||
| Mockito.when(checker.getApisAllowedToAccount(Mockito.eq(requested), Mockito.anyList())).thenReturn(requestedApis); | ||
| Mockito.when(checker.getApisAllowedToAccount(Mockito.eq(caller), Mockito.anyList())).thenReturn(allApis); | ||
|
|
||
| accountManagerImpl.setApiAccessCheckers(Arrays.asList(checker)); | ||
| setPrivateField(accountManagerImpl, "apiNameList", new ArrayList<>(allApis)); | ||
|
|
||
| accountManagerImpl.checkRoleEscalation(caller, requested); | ||
| } | ||
|
|
||
| @Test(expected = PermissionDeniedException.class) | ||
| public void testCheckRoleEscalationRequestedHasMorePermissionsShouldThrow() throws Exception { | ||
| List<String> allApis = Arrays.asList("api1", "api2", "api3"); | ||
| List<String> requestedApis = Arrays.asList("api1", "api2", "api3"); | ||
| List<String> callerApis = Arrays.asList("api1"); | ||
|
|
||
| APIAclChecker checker = Mockito.mock(APIAclChecker.class); | ||
| Mockito.when(checker.isEnabled()).thenReturn(true); | ||
|
|
||
| Account caller = Mockito.mock(Account.class); | ||
| Account requested = Mockito.mock(Account.class); | ||
|
|
||
| Mockito.when(checker.getApisAllowedToAccount(Mockito.eq(requested), Mockito.anyList())).thenReturn(requestedApis); | ||
| Mockito.when(checker.getApisAllowedToAccount(Mockito.eq(caller), Mockito.anyList())).thenReturn(callerApis); | ||
|
|
||
| accountManagerImpl.setApiAccessCheckers(Arrays.asList(checker)); | ||
| setPrivateField(accountManagerImpl, "apiNameList", new ArrayList<>(allApis)); | ||
|
|
||
| accountManagerImpl.checkRoleEscalation(caller, requested); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCheckRoleEscalationEmptyApiListShouldPass() throws Exception { | ||
| APIAclChecker checker = Mockito.mock(APIAclChecker.class); | ||
| Mockito.when(checker.isEnabled()).thenReturn(true); | ||
| Mockito.when(checker.getApisAllowedToAccount(Mockito.any(Account.class), Mockito.anyList())).thenReturn(Collections.emptyList()); | ||
|
|
||
| Account caller = Mockito.mock(Account.class); | ||
| Account requested = Mockito.mock(Account.class); | ||
|
|
||
| accountManagerImpl.setApiAccessCheckers(Arrays.asList(checker)); | ||
| setPrivateField(accountManagerImpl, "apiNameList", new ArrayList<>()); | ||
|
|
||
| accountManagerImpl.checkRoleEscalation(caller, requested); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCheckRoleEscalationMultipleCheckersAppliedSequentially() throws Exception { | ||
| List<String> allApis = Arrays.asList("api1", "api2", "api3"); | ||
| List<String> afterChecker1 = Arrays.asList("api1", "api2"); | ||
| List<String> afterChecker2 = Arrays.asList("api1"); | ||
|
|
||
| APIAclChecker checker1 = Mockito.mock(APIAclChecker.class); | ||
| Mockito.when(checker1.isEnabled()).thenReturn(true); | ||
| APIAclChecker checker2 = Mockito.mock(APIAclChecker.class); | ||
| Mockito.when(checker2.isEnabled()).thenReturn(true); | ||
|
|
||
| Account caller = Mockito.mock(Account.class); | ||
| Account requested = Mockito.mock(Account.class); | ||
|
|
||
| // requested: checker1 filters to [api1, api2], checker2 further filters to [api1] | ||
| Mockito.when(checker1.getApisAllowedToAccount(Mockito.eq(requested), Mockito.eq(allApis))).thenReturn(afterChecker1); | ||
| Mockito.when(checker2.getApisAllowedToAccount(Mockito.eq(requested), Mockito.eq(afterChecker1))).thenReturn(afterChecker2); | ||
| // caller: same filtering, so no escalation | ||
| Mockito.when(checker1.getApisAllowedToAccount(Mockito.eq(caller), Mockito.eq(afterChecker2))).thenReturn(afterChecker2); | ||
| Mockito.when(checker2.getApisAllowedToAccount(Mockito.eq(caller), Mockito.eq(afterChecker2))).thenReturn(afterChecker2); | ||
|
|
||
| accountManagerImpl.setApiAccessCheckers(Arrays.asList(checker1, checker2)); | ||
| setPrivateField(accountManagerImpl, "apiNameList", new ArrayList<>(allApis)); | ||
|
|
||
| accountManagerImpl.checkRoleEscalation(caller, requested); | ||
| } | ||
| } |
| Mockito.doReturn(Collections.singletonList(permission)).when(roleServiceMock).findAllPermissionsBy(Mockito.anyLong()); | ||
|
|
||
| List<String> apisReceived = apiAccessCheckerSpy.getApisAllowedToUser(getTestRole(), getTestUser(), apiNames); | ||
| Assert.assertEquals(0, apisReceived.size()); | ||
| } | ||
|
|
||
| // --- Tests for checkAccess(Account, String) --- | ||
|
|
||
| @Test(expected = PermissionDeniedException.class) | ||
| public void testCheckAccessAccountNullRoleShouldThrow() { | ||
| Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(null); | ||
| apiAccessCheckerSpy.checkAccess(getTestAccount(), "someApi"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCheckAccessAccountAdminShouldAllow() { | ||
| Account adminAccount = new AccountVO("root admin", 1L, null, Account.Type.ADMIN, "admin-uuid"); | ||
| Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(new RoleVO(1L, "Admin", RoleType.Admin, "default admin role")); | ||
| assertTrue(apiAccessCheckerSpy.checkAccess(adminAccount, "anyApi")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCheckAccessAccountAllowedApi() { | ||
| final String allowedApiName = "someAllowedApi"; | ||
| final RolePermission permission = new RolePermissionVO(1L, allowedApiName, Permission.ALLOW, null); | ||
| Mockito.when(roleServiceMock.findAllPermissionsBy(Mockito.anyLong())).thenReturn(Collections.singletonList(permission)); | ||
| assertTrue(apiAccessCheckerSpy.checkAccess(getTestAccount(), allowedApiName)); | ||
| } | ||
|
|
||
| @Test(expected = PermissionDeniedException.class) | ||
| public void testCheckAccessAccountDeniedApi() { | ||
| final String deniedApiName = "someDeniedApi"; | ||
| final RolePermission permission = new RolePermissionVO(1L, deniedApiName, Permission.DENY, null); | ||
| Mockito.when(roleServiceMock.findAllPermissionsBy(Mockito.anyLong())).thenReturn(Collections.singletonList(permission)); | ||
| apiAccessCheckerSpy.checkAccess(getTestAccount(), deniedApiName); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCheckAccessAccountUsesCachedPermissions() throws Exception { | ||
| // Enable caching by setting a positive cachePeriod | ||
| Field cachePeriodField = DynamicRoleBasedAPIAccessChecker.class.getDeclaredField("cachePeriod"); | ||
| cachePeriodField.setAccessible(true); | ||
| cachePeriodField.set(apiAccessCheckerSpy, 1); | ||
|
|
||
| Field rpCacheField = DynamicRoleBasedAPIAccessChecker.class.getDeclaredField("rolePermissionsCache"); | ||
| rpCacheField.setAccessible(true); | ||
| rpCacheField.set(apiAccessCheckerSpy, new LazyCache<Long, Pair<Role, List<RolePermission>>>(32, 1, apiAccessCheckerSpy::getRolePermissions)); | ||
|
|
||
| final String allowedApiName = "someAllowedApi"; | ||
| final RolePermission permission = new RolePermissionVO(1L, allowedApiName, Permission.ALLOW, null); | ||
| Mockito.when(roleServiceMock.findAllPermissionsBy(Mockito.anyLong())).thenReturn(Collections.singletonList(permission)); | ||
|
|
||
| // First call should populate the cache | ||
| apiAccessCheckerSpy.checkAccess(getTestAccount(), allowedApiName); | ||
| // Second call should use cached permissions and not hit the DAO again | ||
| apiAccessCheckerSpy.checkAccess(getTestAccount(), allowedApiName); | ||
|
|
||
| Mockito.verify(roleServiceMock, Mockito.times(1)).findAllPermissionsBy(Mockito.anyLong()); | ||
| } | ||
|
|
||
| // --- Tests for getApisAllowedToAccount --- | ||
|
|
||
| @Test | ||
| public void testGetApisAllowedToAccountDisabledShouldReturnAll() { | ||
| Mockito.doReturn(false).when(apiAccessCheckerSpy).isEnabled(); | ||
| List<String> input = new ArrayList<>(Arrays.asList("api1", "api2", "api3")); | ||
| List<String> result = apiAccessCheckerSpy.getApisAllowedToAccount(getTestAccount(), input); | ||
| Assert.assertEquals(3, result.size()); | ||
| } | ||
|
|
||
| @Test(expected = PermissionDeniedException.class) | ||
| public void testGetApisAllowedToAccountNullRoleShouldThrow() { | ||
| Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(null); | ||
| apiAccessCheckerSpy.getApisAllowedToAccount(getTestAccount(), new ArrayList<>(Arrays.asList("api1"))); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGetApisAllowedToAccountAdminShouldReturnAll() { | ||
| Account adminAccount = new AccountVO("root admin", 1L, null, Account.Type.ADMIN, "admin-uuid"); | ||
| Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(new RoleVO(1L, "Admin", RoleType.Admin, "default admin role")); | ||
| List<String> input = new ArrayList<>(Arrays.asList("api1", "api2", "api3")); | ||
| List<String> result = apiAccessCheckerSpy.getApisAllowedToAccount(adminAccount, input); | ||
| Assert.assertEquals(3, result.size()); | ||
| Assert.assertEquals(input, result); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGetApisAllowedToAccountFiltersCorrectly() { | ||
| final RolePermission allowPermission = new RolePermissionVO(1L, "allowedApi", Permission.ALLOW, null); | ||
| final RolePermission denyPermission = new RolePermissionVO(1L, "deniedApi", Permission.DENY, null); | ||
| Mockito.when(roleServiceMock.findAllPermissionsBy(Mockito.anyLong())).thenReturn(Arrays.asList(allowPermission, denyPermission)); | ||
| List<String> input = new ArrayList<>(Arrays.asList("allowedApi", "deniedApi", "unknownApi")); | ||
| List<String> result = apiAccessCheckerSpy.getApisAllowedToAccount(getTestAccount(), input); | ||
| Assert.assertEquals(1, result.size()); | ||
| Assert.assertEquals("allowedApi", result.get(0)); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGetApisAllowedToAccountAnnotationFallback() { | ||
| Mockito.when(roleServiceMock.findAllPermissionsBy(Mockito.anyLong())).thenReturn(Collections.emptyList()); | ||
| apiAccessCheckerSpy.addApiToRoleBasedAnnotationsMap(RoleType.User, "annotatedApi"); | ||
| List<String> input = new ArrayList<>(Arrays.asList("annotatedApi", "unknownApi")); | ||
| List<String> result = apiAccessCheckerSpy.getApisAllowedToAccount(getTestAccount(), input); | ||
| Assert.assertEquals(1, result.size()); | ||
| Assert.assertEquals("annotatedApi", result.get(0)); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGetApisAllowedToAccountUsesCachedPermissions() { | ||
| try { | ||
| // Ensure caching is enabled by setting a positive cachePeriod | ||
| Field cachePeriodField = DynamicRoleBasedAPIAccessChecker.class.getDeclaredField("cachePeriod"); | ||
| cachePeriodField.setAccessible(true); | ||
| cachePeriodField.set(apiAccessCheckerSpy, 1); | ||
|
|
||
| Field rpCacheField = DynamicRoleBasedAPIAccessChecker.class.getDeclaredField("rolePermissionsCache"); | ||
| rpCacheField.setAccessible(true); | ||
| rpCacheField.set(apiAccessCheckerSpy, new LazyCache<Long, Pair<Role, List<RolePermission>>>(32, 1, apiAccessCheckerSpy::getRolePermissions)); | ||
|
|
||
| final RolePermission permission = new RolePermissionVO(1L, "api1", Permission.ALLOW, null); | ||
| Mockito.when(roleServiceMock.findAllPermissionsBy(Mockito.anyLong())).thenReturn(Collections.singletonList(permission)); | ||
|
|
||
| Account account = getTestAccount(); | ||
| List<String> apis = new ArrayList<>(Arrays.asList("api1")); | ||
|
|
||
| // First call should load permissions from the DAO and populate the cache | ||
| apiAccessCheckerSpy.getApisAllowedToAccount(account, apis); | ||
| // Second call should use cached permissions and not hit the DAO again | ||
| apiAccessCheckerSpy.getApisAllowedToAccount(account, apis); | ||
|
|
||
| Mockito.verify(roleServiceMock, Mockito.times(1)).findAllPermissionsBy(Mockito.anyLong()); | ||
| } catch (NoSuchFieldException | IllegalAccessException e) { | ||
| Assert.fail("Failed to set cachePeriod for test: " + e.getMessage()); | ||
| } | ||
| } | ||
| } |
| try { | ||
| checkApiAccess(apiCheckers, requested, command); | ||
| } catch (PermissionDeniedException pde) { | ||
| if (logger.isTraceEnabled()) { | ||
| logger.trace(String.format( | ||
| "Checking for permission to \"%s\" is irrelevant as it is not requested for %s [%s]", | ||
| command, | ||
| requested.getAccountName(), | ||
| requested.getUuid() | ||
| ) | ||
| ); | ||
| } | ||
| continue; | ||
| } |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 4.22 #13881 +/- ##
============================================
- Coverage 17.71% 17.70% -0.02%
+ Complexity 15858 15836 -22
============================================
Files 5926 5925 -1
Lines 533613 533539 -74
Branches 65285 65274 -11
============================================
- Hits 94517 94444 -73
- Misses 428415 428419 +4
+ Partials 10681 10676 -5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|


Reverts #12973