Skip to content

Commit f84e043

Browse files
authored
Fix being able to expunge a VM through destroyVirtualMachine even when role rule does not allow (#8689)
1 parent 5bf81cf commit f84e043

File tree

8 files changed

+177
-21
lines changed

8 files changed

+177
-21
lines changed

plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,4 +514,9 @@ public String getConfigComponentName() {
514514
public ConfigKey<?>[] getConfigKeys() {
515515
return null;
516516
}
517+
518+
@Override
519+
public void checkApiAccess(Account account, String command) throws PermissionDeniedException {
520+
521+
}
517522
}

server/src/main/java/com/cloud/user/AccountManager.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,6 @@ void buildACLViewSearchCriteria(SearchCriteria<? extends ControlledViewEntity> s
199199
UserTwoFactorAuthenticationSetupResponse setupUserTwoFactorAuthentication(SetupUserTwoFactorAuthenticationCmd cmd);
200200

201201
List<String> getApiNameList();
202+
203+
void checkApiAccess(Account caller, String command);
202204
}

server/src/main/java/com/cloud/user/AccountManagerImpl.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,12 @@ private void checkApiAccess(List<APIChecker> apiCheckers, Account caller, String
13691369
}
13701370
}
13711371

1372+
@Override
1373+
public void checkApiAccess(Account caller, String command) {
1374+
List<APIChecker> apiCheckers = getEnabledApiCheckers();
1375+
checkApiAccess(apiCheckers, caller, command);
1376+
}
1377+
13721378
@NotNull
13731379
private List<APIChecker> getEnabledApiCheckers() {
13741380
// we are really only interested in the dynamic access checker

server/src/main/java/com/cloud/vm/UserVmManagerImpl.java

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,11 @@
6565
import org.apache.cloudstack.annotation.dao.AnnotationDao;
6666
import org.apache.cloudstack.api.ApiCommandResourceType;
6767
import org.apache.cloudstack.api.ApiConstants;
68+
import org.apache.cloudstack.api.BaseCmd;
6869
import org.apache.cloudstack.api.BaseCmd.HTTPMethod;
6970
import org.apache.cloudstack.api.command.admin.vm.AssignVMCmd;
7071
import org.apache.cloudstack.api.command.admin.vm.DeployVMCmdByAdmin;
72+
import org.apache.cloudstack.api.command.admin.vm.ExpungeVMCmd;
7173
import org.apache.cloudstack.api.command.admin.vm.RecoverVMCmd;
7274
import org.apache.cloudstack.api.command.user.vm.AddNicToVMCmd;
7375
import org.apache.cloudstack.api.command.user.vm.DeployVMCmd;
@@ -3328,6 +3330,27 @@ public UserVm rebootVirtualMachine(RebootVMCmd cmd) throws InsufficientCapacityE
33283330
return null;
33293331
}
33303332

3333+
/**
3334+
* Encapsulates AllowUserExpungeRecoverVm so we can unit test checkExpungeVmPermission.
3335+
*/
3336+
protected boolean getConfigAllowUserExpungeRecoverVm(Long accountId) {
3337+
return AllowUserExpungeRecoverVm.valueIn(accountId);
3338+
}
3339+
3340+
protected void checkExpungeVmPermission (Account callingAccount) {
3341+
logger.debug(String.format("Checking if [%s] has permission for expunging VMs.", callingAccount));
3342+
if (!_accountMgr.isAdmin(callingAccount.getId()) && !getConfigAllowUserExpungeRecoverVm(callingAccount.getId())) {
3343+
logger.error(String.format("Parameter [%s] can only be passed by Admin accounts or when the allow.user.expunge.recover.vm key is true.", ApiConstants.EXPUNGE));
3344+
throw new PermissionDeniedException("Account does not have permission for expunging.");
3345+
}
3346+
try {
3347+
_accountMgr.checkApiAccess(callingAccount, BaseCmd.getCommandNameByClass(ExpungeVMCmd.class));
3348+
} catch (PermissionDeniedException ex) {
3349+
logger.error(String.format("Role [%s] of [%s] does not have permission for expunging VMs.", callingAccount.getRoleId(), callingAccount));
3350+
throw new PermissionDeniedException("Account does not have permission for expunging.");
3351+
}
3352+
}
3353+
33313354
protected void checkPluginsIfVmCanBeDestroyed(UserVm vm) {
33323355
try {
33333356
KubernetesServiceHelper kubernetesServiceHelper =
@@ -3345,10 +3368,10 @@ public UserVm destroyVm(DestroyVMCmd cmd) throws ResourceUnavailableException, C
33453368
long vmId = cmd.getId();
33463369
boolean expunge = cmd.getExpunge();
33473370

3348-
// When trying to expunge, permission is denied when the caller is not an admin and the AllowUserExpungeRecoverVm is false for the caller.
3349-
if (expunge && !_accountMgr.isAdmin(ctx.getCallingAccount().getId()) && !AllowUserExpungeRecoverVm.valueIn(cmd.getEntityOwnerId())) {
3350-
throw new PermissionDeniedException("Parameter " + ApiConstants.EXPUNGE + " can be passed by Admin only. Or when the allow.user.expunge.recover.vm key is set.");
3371+
if (expunge) {
3372+
checkExpungeVmPermission(ctx.getCallingAccount());
33513373
}
3374+
33523375
// check if VM exists
33533376
UserVmVO vm = _vmDao.findById(vmId);
33543377

server/src/test/java/com/cloud/user/MockAccountManagerImpl.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,10 @@ public UserTwoFactorAuthenticator getUserTwoFactorAuthenticationProvider(Long do
464464
return null;
465465
}
466466

467+
@Override
468+
public void checkApiAccess(Account account, String command) throws PermissionDeniedException {
469+
470+
}
467471
@Override
468472
public void checkAccess(User user, ControlledEntity entity)
469473
throws PermissionDeniedException {

server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,4 +1595,40 @@ public void testGetRootVolumeSizeForVmRestoreNullDiskOfferingAndEmptyDetails() {
15951595
Long actualSize = userVmManagerImpl.getRootVolumeSizeForVmRestore(null, template, userVm, diskOffering, details, false);
15961596
Assert.assertEquals(20 * GiB_TO_BYTES, actualSize.longValue());
15971597
}
1598+
1599+
@Test
1600+
public void checkExpungeVMPermissionTestAccountIsNotAdminConfigFalseThrowsPermissionDeniedException () {
1601+
Mockito.doReturn(false).when(accountManager).isAdmin(Mockito.anyLong());
1602+
Mockito.doReturn(false).when(userVmManagerImpl).getConfigAllowUserExpungeRecoverVm(Mockito.anyLong());
1603+
1604+
Assert.assertThrows(PermissionDeniedException.class, () -> userVmManagerImpl.checkExpungeVmPermission(accountMock));
1605+
}
1606+
@Test
1607+
public void checkExpungeVmPermissionTestAccountIsNotAdminConfigTrueNoApiAccessThrowsPermissionDeniedException () {
1608+
Mockito.doReturn(false).when(accountManager).isAdmin(Mockito.anyLong());
1609+
Mockito.doReturn(true).when(userVmManagerImpl).getConfigAllowUserExpungeRecoverVm(Mockito.anyLong());
1610+
Mockito.doThrow(PermissionDeniedException.class).when(accountManager).checkApiAccess(accountMock, "expungeVirtualMachine");
1611+
1612+
Assert.assertThrows(PermissionDeniedException.class, () -> userVmManagerImpl.checkExpungeVmPermission(accountMock));
1613+
}
1614+
@Test
1615+
public void checkExpungeVmPermissionTestAccountIsNotAdminConfigTrueHasApiAccessReturnNothing () {
1616+
Mockito.doReturn(false).when(accountManager).isAdmin(Mockito.anyLong());
1617+
Mockito.doReturn(true).when(userVmManagerImpl).getConfigAllowUserExpungeRecoverVm(Mockito.anyLong());
1618+
1619+
userVmManagerImpl.checkExpungeVmPermission(accountMock);
1620+
}
1621+
@Test
1622+
public void checkExpungeVmPermissionTestAccountIsAdminNoApiAccessThrowsPermissionDeniedException () {
1623+
Mockito.doReturn(true).when(accountManager).isAdmin(Mockito.anyLong());
1624+
Mockito.doThrow(PermissionDeniedException.class).when(accountManager).checkApiAccess(accountMock, "expungeVirtualMachine");
1625+
1626+
Assert.assertThrows(PermissionDeniedException.class, () -> userVmManagerImpl.checkExpungeVmPermission(accountMock));
1627+
}
1628+
@Test
1629+
public void checkExpungeVmPermissionTestAccountIsAdminHasApiAccessReturnNothing () {
1630+
Mockito.doReturn(true).when(accountManager).isAdmin(Mockito.anyLong());
1631+
1632+
userVmManagerImpl.checkExpungeVmPermission(accountMock);
1633+
}
15981634
}

test/integration/smoke/test_vm_life_cycle.py

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from marvin.lib.utils import *
3232

3333
from marvin.lib.base import (Account,
34+
Role,
3435
ServiceOffering,
3536
VirtualMachine,
3637
Host,
@@ -94,17 +95,21 @@ def setUpClass(cls):
9495

9596
cls.services["iso1"]["zoneid"] = cls.zone.id
9697

98+
cls._cleanup = []
99+
97100
cls.account = Account.create(
98101
cls.apiclient,
99102
cls.services["account"],
100103
domainid=cls.domain.id
101104
)
105+
cls._cleanup.append(cls.account)
102106
cls.debug(cls.account.id)
103107

104108
cls.service_offering = ServiceOffering.create(
105109
cls.apiclient,
106110
cls.services["service_offerings"]["tiny"]
107111
)
112+
cls._cleanup.append(cls.service_offering)
108113

109114
cls.virtual_machine = VirtualMachine.create(
110115
cls.apiclient,
@@ -115,17 +120,9 @@ def setUpClass(cls):
115120
mode=cls.services['mode']
116121
)
117122

118-
cls.cleanup = [
119-
cls.service_offering,
120-
cls.account
121-
]
122-
123123
@classmethod
124124
def tearDownClass(cls):
125-
try:
126-
cleanup_resources(cls.apiclient, cls.cleanup)
127-
except Exception as e:
128-
raise Exception("Warning: Exception during cleanup : %s" % e)
125+
super(TestDeployVM, cls).tearDownClass()
129126

130127
def setUp(self):
131128
self.apiclient = self.testClient.getApiClient()
@@ -262,11 +259,7 @@ def test_deploy_vm_multiple(self):
262259
)
263260

264261
def tearDown(self):
265-
try:
266-
# Clean up, terminate the created instance, volumes and snapshots
267-
cleanup_resources(self.apiclient, self.cleanup)
268-
except Exception as e:
269-
raise Exception("Warning: Exception during cleanup : %s" % e)
262+
super(TestDeployVM, self).tearDown()
270263

271264

272265
class TestVMLifeCycle(cloudstackTestCase):
@@ -279,7 +272,7 @@ def setUpClass(cls):
279272
cls.hypervisor = testClient.getHypervisorInfo()
280273

281274
# Get Zone, Domain and templates
282-
domain = get_domain(cls.apiclient)
275+
cls.domain = get_domain(cls.apiclient)
283276
cls.zone = get_zone(cls.apiclient, cls.testClient.getZoneForTests())
284277
cls.services['mode'] = cls.zone.networktype
285278

@@ -309,7 +302,7 @@ def setUpClass(cls):
309302
cls.account = Account.create(
310303
cls.apiclient,
311304
cls.services["account"],
312-
domainid=domain.id
305+
domainid=cls.domain.id
313306
)
314307

315308
cls.small_offering = ServiceOffering.create(
@@ -362,6 +355,7 @@ def setUp(self):
362355
self.cleanup = []
363356

364357
def tearDown(self):
358+
# This should be a super call instead (like tearDownClass), which reverses cleanup order. Kept for now since fixing requires adjusting test 12.
365359
try:
366360
# Clean up, terminate the created ISOs
367361
cleanup_resources(self.apiclient, self.cleanup)
@@ -929,7 +923,7 @@ def test_12_start_vm_multiple_volumes_allocated(self):
929923
domainid=self.account.domainid,
930924
diskofferingid=custom_disk_offering.id
931925
)
932-
self.cleanup.append(volume)
926+
self.cleanup.append(volume) # Needs adjusting when changing tearDown to a super call, since it will try to delete an attached volume.
933927
VirtualMachine.attach_volume(vm, self.apiclient, volume)
934928

935929
# Start the VM
@@ -955,6 +949,92 @@ def test_12_start_vm_multiple_volumes_allocated(self):
955949
"Check virtual machine is in running state"
956950
)
957951

952+
@attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], required_hardware="false")
953+
def test_13_destroy_and_expunge_vm(self):
954+
"""Test destroy virtual machine with expunge parameter depending on whether the caller's role has expunge permission.
955+
"""
956+
# Setup steps:
957+
# 1. Create role with DENY expunge permission.
958+
# 2. Create account with said role.
959+
# 3. Create a VM of said account.
960+
# 4. Create a VM of cls.account
961+
# Validation steps:
962+
# 1. Destroy the VM with the created account and verify it was not destroyed.
963+
# 1. Destroy the other VM with cls.account and verify it was expunged.
964+
965+
role = Role.importRole(
966+
self.apiclient,
967+
{
968+
"name": "MarvinFake Import Role ",
969+
"type": "DomainAdmin",
970+
"description": "Fake Import Domain Admin Role created by Marvin test",
971+
"rules" : [{"rule":"list*", "permission":"allow","description":"Listing apis"},
972+
{"rule":"get*", "permission":"allow","description":"Get apis"},
973+
{"rule":"update*", "permission":"allow","description":"Update apis"},
974+
{"rule":"queryAsyncJobResult", "permission":"allow","description":"Query async job result"},
975+
{"rule":"deployVirtualMachine", "permission":"allow","description":"Deploy virtual machine"},
976+
{"rule":"destroyVirtualMachine", "permission":"allow","description":"Destroy virtual machine"},
977+
{"rule":"expungeVirtualMachine", "permission":"deny","description":"Expunge virtual machine"}]
978+
},
979+
)
980+
self.cleanup.append(role)
981+
982+
domadm = Account.create(
983+
self.apiclient,
984+
self.services["account"],
985+
admin=True,
986+
roleid=role.id,
987+
domainid=self.domain.id
988+
)
989+
self.cleanup[-1]=domadm # Hacky way to reverse cleanup order to avoid deleting the role before account. Remove this line when tearDown is changed to call super().
990+
self.cleanup.append(role) # Should be self.cleanup.append(domadm) when tearDown is changed to call super().
991+
992+
domadm_apiclient = self.testClient.getUserApiClient(UserName=domadm.name, DomainName=self.domain.name, type=1)
993+
994+
vm1 = VirtualMachine.create(
995+
self.apiclient,
996+
self.services["small"],
997+
accountid=self.account.name,
998+
domainid=self.account.domainid,
999+
serviceofferingid=self.small_offering.id,
1000+
)
1001+
1002+
vm2 = VirtualMachine.create(
1003+
domadm_apiclient,
1004+
self.services["small"],
1005+
accountid=domadm.name,
1006+
domainid=domadm.domainid,
1007+
serviceofferingid=self.small_offering.id,
1008+
)
1009+
1010+
self.debug("Expunge VM-ID: %s" % vm1.id)
1011+
1012+
cmd = destroyVirtualMachine.destroyVirtualMachineCmd()
1013+
cmd.id = vm1.id
1014+
cmd.expunge = True
1015+
response = self.apiclient.destroyVirtualMachine(cmd)
1016+
1017+
self.debug("response: %s" % response)
1018+
self.debug("response: %s" % response.id)
1019+
self.assertEqual(
1020+
response.id,
1021+
None,
1022+
"Check if VM was expunged.",
1023+
)
1024+
1025+
self.debug("Expunge VM-ID: %s" % vm2.id)
1026+
1027+
cmd = destroyVirtualMachine.destroyVirtualMachineCmd()
1028+
cmd.id = vm2.id
1029+
cmd.expunge = True
1030+
try:
1031+
domadm_apiclient.destroyVirtualMachine(cmd)
1032+
self.failed("Destroy VM with expunge should have raised an exception.")
1033+
except:
1034+
self.debug("Expected exception! Keep going.")
1035+
1036+
return
1037+
9581038

9591039
class TestSecuredVmMigration(cloudstackTestCase):
9601040

ui/src/views/compute/DestroyVM.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
<a-form-item
3535
name="expunge"
3636
ref="expunge"
37-
v-if="$store.getters.userInfo.roletype === 'Admin' || $store.getters.features.allowuserexpungerecovervm">
37+
v-if="'expungeVirtualMachine' in $store.getters.apis && ($store.getters.userInfo.roletype === 'Admin' || $store.getters.features.allowuserexpungerecovervm)">
3838
<template #label>
3939
<tooltip-label :title="$t('label.expunge')" :tooltip="apiParams.expunge.description"/>
4040
</template>

0 commit comments

Comments
 (0)