Skip to content

Commit

Permalink
server, api: account and api entity access improvements
Browse files Browse the repository at this point in the history
Fixes domain-admin access check to prevent unauthorized access.
Introduces a new non-dynamic global setting - api.allow.internal.db.ids
to control whether to allow using internal DB IDs as API parameters or
not. Default value for the global setting is false.

Co-authored-by: Fabricio Duarte <[email protected]>
Co-authored-by: nvazquez <[email protected]>
Co-authored-by: Abhishek Kumar <[email protected]>
  • Loading branch information
3 people committed Aug 6, 2024
1 parent cf0e44d commit 2e0024e
Show file tree
Hide file tree
Showing 6 changed files with 488 additions and 21 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
fail-fast: false
matrix:
tests: [ "smoke/test_accounts
smoke/test_account_access
smoke/test_affinity_groups
smoke/test_affinity_groups_projects
smoke/test_annotations
Expand Down
68 changes: 48 additions & 20 deletions server/src/main/java/com/cloud/acl/DomainChecker.java
Original file line number Diff line number Diff line change
Expand Up @@ -208,38 +208,66 @@ public boolean checkAccess(Account caller, ControlledEntity entity, AccessType a

return true;
} else if (entity instanceof Network && accessType != null && accessType == AccessType.UseEntry) {
_networkMgr.checkNetworkPermissions(caller, (Network)entity);
_networkMgr.checkNetworkPermissions(caller, (Network) entity);
} else if (entity instanceof Network && accessType != null && accessType == AccessType.OperateEntry) {
_networkMgr.checkNetworkOperatePermissions(caller, (Network)entity);
} else if (entity instanceof VirtualRouter) {
_networkMgr.checkRouterPermissions(caller, (VirtualRouter)entity);
} else if (entity instanceof AffinityGroup) {
return false;
} else {
if (_accountService.isNormalUser(caller.getId())) {
Account account = _accountDao.findById(entity.getAccountId());
String errorMessage = String.format("%s does not have permission to operate with resource", caller);
if (account != null && account.getType() == Account.Type.PROJECT) {
//only project owner can delete/modify the project
if (accessType != null && accessType == AccessType.ModifyProject) {
if (!_projectMgr.canModifyProjectAccount(caller, account.getId())) {
throw new PermissionDeniedException(errorMessage);
}
} else if (!_projectMgr.canAccessProjectAccount(caller, account.getId())) {
throw new PermissionDeniedException(errorMessage);
}
checkOperationPermitted(caller, entity);
} else {
if (caller.getId() != entity.getAccountId()) {
throw new PermissionDeniedException(errorMessage);
}
validateCallerHasAccessToEntityOwner(caller, entity, accessType);
}
return true;
}

protected void validateCallerHasAccessToEntityOwner(Account caller, ControlledEntity entity, AccessType accessType) {
PermissionDeniedException exception = new PermissionDeniedException("Caller does not have permission to operate with provided resource.");
String entityLog = String.format("entity [owner ID: %d, type: %s]", entity.getAccountId(),
entity.getEntityType().getSimpleName());

if (_accountService.isRootAdmin(caller.getId())) {
return;
}

if (caller.getId() == entity.getAccountId()) {
return;
}

Account owner = _accountDao.findById(entity.getAccountId());
if (owner == null) {
s_logger.error(String.format("Owner not found for %s", entityLog));
throw exception;
}

Account.Type callerAccountType = caller.getType();
if ((callerAccountType == Account.Type.DOMAIN_ADMIN || callerAccountType == Account.Type.RESOURCE_DOMAIN_ADMIN) &&
_domainDao.isChildDomain(caller.getDomainId(), owner.getDomainId())) {
return;
}

if (owner.getType() == Account.Type.PROJECT) {
// only project owner can delete/modify the project
if (accessType == AccessType.ModifyProject) {
if (!_projectMgr.canModifyProjectAccount(caller, owner.getId())) {
s_logger.error(String.format("Caller ID: %d does not have permission to modify project with " +
"owner ID: %d", caller.getId(), owner.getId()));
throw exception;
}
} else if (!_projectMgr.canAccessProjectAccount(caller, owner.getId())) {
s_logger.error(String.format("Caller ID: %d does not have permission to access project with " +
"owner ID: %d", caller.getId(), owner.getId()));
throw exception;
}
checkOperationPermitted(caller, entity);
return;
}
return true;

s_logger.error(String.format("Caller ID: %d does not have permission to access %s", caller.getId(), entityLog));
throw exception;
}

private boolean checkOperationPermitted(Account caller, ControlledEntity entity) {
protected boolean checkOperationPermitted(Account caller, ControlledEntity entity) {
User user = CallContext.current().getCallingUser();
Project project = projectDao.findByProjectAccountId(entity.getAccountId());
if (project == null) {
Expand Down
18 changes: 17 additions & 1 deletion server/src/main/java/com/cloud/user/AccountManagerImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -2717,7 +2717,9 @@ public Map<String, String> getKeys(Long userId) {
throw new InvalidParameterValueException("Unable to find user by id");
}
final ControlledEntity account = getAccount(getUserAccountById(userId).getAccountId()); //Extracting the Account from the userID of the requested user.
checkAccess(CallContext.current().getCallingUser(), account);
User caller = CallContext.current().getCallingUser();
preventRootDomainAdminAccessToRootAdminKeys(caller, account);
checkAccess(caller, account);

Map<String, String> keys = new HashMap<String, String>();
keys.put("apikey", user.getApiKey());
Expand All @@ -2726,6 +2728,19 @@ public Map<String, String> getKeys(Long userId) {
return keys;
}

protected void preventRootDomainAdminAccessToRootAdminKeys(User caller, ControlledEntity account) {
if (isDomainAdminForRootDomain(caller) && isRootAdmin(account.getAccountId())) {
String msg = String.format("Caller Username %s does not have access to root admin keys", caller.getUsername());
s_logger.error(msg);
throw new PermissionDeniedException(msg);
}
}

protected boolean isDomainAdminForRootDomain(User callingUser) {
AccountVO caller = _accountDao.findById(callingUser.getAccountId());
return caller.getType() == Account.Type.DOMAIN_ADMIN && caller.getDomainId() == Domain.ROOT_DOMAIN;
}

@Override
public List<UserTwoFactorAuthenticator> listUserTwoFactorAuthenticationProviders() {
return userTwoFactorAuthenticationProviders;
Expand Down Expand Up @@ -2760,6 +2775,7 @@ public String[] createApiKeyAndSecretKey(RegisterCmd cmd) {
}

Account account = _accountDao.findById(user.getAccountId());
preventRootDomainAdminAccessToRootAdminKeys(user, account);
checkAccess(caller, null, true, account);

// don't allow updating system user
Expand Down
166 changes: 166 additions & 0 deletions server/src/test/java/com/cloud/acl/DomainCheckerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package com.cloud.acl;

import org.apache.cloudstack.acl.ControlledEntity;
import org.apache.cloudstack.acl.SecurityChecker;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;

import com.cloud.domain.dao.DomainDao;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.projects.ProjectManager;
import com.cloud.user.Account;
import com.cloud.user.AccountService;
import com.cloud.user.AccountVO;
import com.cloud.user.dao.AccountDao;
import com.cloud.utils.Ternary;

@RunWith(MockitoJUnitRunner.class)
public class DomainCheckerTest {

@Mock
AccountService _accountService;
@Mock
AccountDao _accountDao;
@Mock
DomainDao _domainDao;
@Mock
ProjectManager _projectMgr;

@Spy
@InjectMocks
DomainChecker domainChecker;

private ControlledEntity getMockedEntity(long accountId) {
ControlledEntity entity = Mockito.mock(Account.class);
Mockito.when(entity.getAccountId()).thenReturn(accountId);
Mockito.when(entity.getEntityType()).thenReturn((Class)Account.class);
return entity;
}

@Test
public void testRootAdminHasAccess() {
Account rootAdmin = Mockito.mock(Account.class);
Mockito.when(rootAdmin.getId()).thenReturn(1L);
ControlledEntity entity = getMockedEntity(2L);
Mockito.when(_accountService.isRootAdmin(rootAdmin.getId())).thenReturn(true);

domainChecker.validateCallerHasAccessToEntityOwner(rootAdmin, entity, SecurityChecker.AccessType.ModifyProject);
}

@Test
public void testCallerIsOwner() {
Account caller = Mockito.mock(Account.class);
Mockito.when(caller.getId()).thenReturn(1L);
ControlledEntity entity = getMockedEntity(1L);

domainChecker.validateCallerHasAccessToEntityOwner(caller, entity, SecurityChecker.AccessType.ModifyProject);
}

@Test(expected = PermissionDeniedException.class)
public void testOwnerNotFound() {
Account caller = Mockito.mock(Account.class);
Mockito.when(caller.getId()).thenReturn(1L);
ControlledEntity entity = getMockedEntity(2L);
Mockito.when(_accountDao.findById(entity.getAccountId())).thenReturn(null);

domainChecker.validateCallerHasAccessToEntityOwner(caller, entity, SecurityChecker.AccessType.ModifyProject);
}

@Test
public void testDomainAdminHasAccess() {
Account caller = Mockito.mock(Account.class);
Mockito.when(caller.getId()).thenReturn(1L);
Mockito.when(caller.getDomainId()).thenReturn(100L);
Mockito.when(caller.getType()).thenReturn(Account.Type.DOMAIN_ADMIN);
ControlledEntity entity = getMockedEntity(2L);
AccountVO owner = Mockito.mock(AccountVO.class);
Mockito.when(owner.getDomainId()).thenReturn(101L);
Mockito.when(_accountDao.findById(entity.getAccountId())).thenReturn(owner);
Mockito.when(_domainDao.isChildDomain(100L, 101L)).thenReturn(true);

domainChecker.validateCallerHasAccessToEntityOwner(caller, entity, SecurityChecker.AccessType.ModifyProject);
}

private Ternary<Account, ControlledEntity, AccountVO> getProjectAccessCheckResources() {
Account caller = Mockito.mock(Account.class);
Mockito.when(caller.getId()).thenReturn(100L);
Mockito.when(caller.getType()).thenReturn(Account.Type.PROJECT);
ControlledEntity entity = getMockedEntity(2L);
AccountVO projectAccount = Mockito.mock(AccountVO.class);
Mockito.when(projectAccount.getId()).thenReturn(2L);
Mockito.when(projectAccount.getType()).thenReturn(Account.Type.PROJECT);
return new Ternary<>(caller, entity, projectAccount);
}

@Test
public void testProjectOwnerCanModify() {
Ternary<Account, ControlledEntity, AccountVO> resources = getProjectAccessCheckResources();
Account caller = resources.first();
ControlledEntity entity = resources.second();
AccountVO projectAccount = resources.third();
Mockito.when(_accountDao.findById(entity.getAccountId())).thenReturn(projectAccount);
Mockito.when(_projectMgr.canModifyProjectAccount(caller, projectAccount.getId())).thenReturn(true);
Mockito.doReturn(true).when(domainChecker).checkOperationPermitted(caller, entity);

domainChecker.validateCallerHasAccessToEntityOwner(caller, entity, SecurityChecker.AccessType.ModifyProject);
}

@Test(expected = PermissionDeniedException.class)
public void testProjectOwnerCannotModify() {
Ternary<Account, ControlledEntity, AccountVO> resources = getProjectAccessCheckResources();
Account caller = resources.first();
ControlledEntity entity = resources.second();
AccountVO projectAccount = resources.third();
Mockito.when(_accountDao.findById(entity.getAccountId())).thenReturn(projectAccount);
Mockito.when(_projectMgr.canModifyProjectAccount(caller, projectAccount.getId())).thenReturn(false);

domainChecker.validateCallerHasAccessToEntityOwner(caller, entity, SecurityChecker.AccessType.ModifyProject);
}

@Test
public void testProjectOwnerCanAccess() {
Ternary<Account, ControlledEntity, AccountVO> resources = getProjectAccessCheckResources();
Account caller = resources.first();
ControlledEntity entity = resources.second();
AccountVO projectAccount = resources.third();
Mockito.when(_accountDao.findById(entity.getAccountId())).thenReturn(projectAccount);
Mockito.when(_projectMgr.canAccessProjectAccount(caller, projectAccount.getId())).thenReturn(true);
Mockito.doReturn(true).when(domainChecker).checkOperationPermitted(caller, entity);

domainChecker.validateCallerHasAccessToEntityOwner(caller, entity, SecurityChecker.AccessType.ListEntry);
}

@Test(expected = PermissionDeniedException.class)
public void testProjectOwnerCannotAccess() {
Ternary<Account, ControlledEntity, AccountVO> resources = getProjectAccessCheckResources();
Account caller = resources.first();
ControlledEntity entity = resources.second();
AccountVO projectAccount = resources.third();
Mockito.when(_accountDao.findById(entity.getAccountId())).thenReturn(projectAccount);
Mockito.when(_projectMgr.canAccessProjectAccount(caller, projectAccount.getId())).thenReturn(false);

domainChecker.validateCallerHasAccessToEntityOwner(caller, entity, SecurityChecker.AccessType.ListEntry);
}

}
58 changes: 58 additions & 0 deletions server/src/test/java/com/cloud/user/AccountManagerImplTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.Map;
import java.util.HashMap;

import org.apache.cloudstack.acl.ControlledEntity;
import org.apache.cloudstack.acl.SecurityChecker.AccessType;
import org.apache.cloudstack.api.command.admin.user.GetUserKeysCmd;
import org.apache.cloudstack.api.command.admin.user.UpdateUserCmd;
Expand Down Expand Up @@ -240,6 +241,63 @@ public void testgetUserCmd() {
accountManagerImpl.getKeys(_listkeyscmd);
}

@Test(expected = PermissionDeniedException.class)
public void testGetUserKeysCmdDomainAdminRootAdminUser() {
CallContext.register(callingUser, callingAccount);
Mockito.when(_listkeyscmd.getID()).thenReturn(2L);
Mockito.when(accountManagerImpl.getActiveUser(2L)).thenReturn(userVoMock);
Mockito.when(userAccountDaoMock.findById(2L)).thenReturn(userAccountVO);
Mockito.when(userAccountVO.getAccountId()).thenReturn(2L);
Mockito.when(userDetailsDaoMock.listDetailsKeyPairs(Mockito.anyLong())).thenReturn(null);

// Queried account - admin account
AccountVO adminAccountMock = Mockito.mock(AccountVO.class);
Mockito.when(adminAccountMock.getAccountId()).thenReturn(2L);
Mockito.when(_accountDao.findByIdIncludingRemoved(2L)).thenReturn(adminAccountMock);
Mockito.lenient().when(accountService.isRootAdmin(2L)).thenReturn(true);
Mockito.lenient().when(securityChecker.checkAccess(Mockito.any(Account.class),
Mockito.nullable(ControlledEntity.class), Mockito.nullable(AccessType.class), Mockito.anyString())).thenReturn(true);

// Calling account is domain admin of the ROOT domain
Mockito.lenient().when(callingAccount.getType()).thenReturn(Account.Type.DOMAIN_ADMIN);
Mockito.lenient().when(callingAccount.getDomainId()).thenReturn(Domain.ROOT_DOMAIN);

Mockito.lenient().when(callingUser.getAccountId()).thenReturn(2L);
Mockito.lenient().when(_accountDao.findById(2L)).thenReturn(callingAccount);

Mockito.lenient().when(accountService.isDomainAdmin(Mockito.anyLong())).thenReturn(Boolean.TRUE);
Mockito.lenient().when(accountMock.getAccountId()).thenReturn(2L);

accountManagerImpl.getKeys(_listkeyscmd);
}

@Test
public void testPreventRootDomainAdminAccessToRootAdminKeysNormalUser() {
User user = Mockito.mock(User.class);
ControlledEntity entity = Mockito.mock(ControlledEntity.class);
Mockito.when(user.getAccountId()).thenReturn(1L);
AccountVO account = Mockito.mock(AccountVO.class);
Mockito.when(account.getType()).thenReturn(Account.Type.NORMAL);
Mockito.when(_accountDao.findById(1L)).thenReturn(account);
accountManagerImpl.preventRootDomainAdminAccessToRootAdminKeys(user, entity);
Mockito.verify(accountManagerImpl, Mockito.never()).isRootAdmin(Mockito.anyLong());
}

@Test(expected = PermissionDeniedException.class)
public void testPreventRootDomainAdminAccessToRootAdminKeysRootDomainAdminUser() {
User user = Mockito.mock(User.class);
ControlledEntity entity = Mockito.mock(ControlledEntity.class);
Mockito.when(user.getAccountId()).thenReturn(1L);
AccountVO account = Mockito.mock(AccountVO.class);
Mockito.when(account.getType()).thenReturn(Account.Type.DOMAIN_ADMIN);
Mockito.when(account.getDomainId()).thenReturn(Domain.ROOT_DOMAIN);
Mockito.when(_accountDao.findById(1L)).thenReturn(account);
Mockito.when(entity.getAccountId()).thenReturn(1L);
Mockito.lenient().when(securityChecker.checkAccess(Mockito.any(Account.class),
Mockito.nullable(ControlledEntity.class), Mockito.nullable(AccessType.class), Mockito.anyString())).thenReturn(true);
accountManagerImpl.preventRootDomainAdminAccessToRootAdminKeys(user, entity);
}

@Test
public void updateUserTestTimeZoneAndEmailNull() {
prepareMockAndExecuteUpdateUserTest(0);
Expand Down
Loading

0 comments on commit 2e0024e

Please sign in to comment.