From 4400e02a1b243a962446d30763a196f9c3c53775 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Tue, 3 Sep 2024 15:53:08 +0530 Subject: [PATCH] framework/config,server: configkey caching (#472) Added caching for ConfigKey value retrievals based on the Caffeine in-memory caching library. https://github.com/ben-manes/caffeine Currently, expire time for a cache is 1 minute and each update of the config key invalidates the cache. Signed-off-by: Abhishek Kumar --- .github/workflows/ci.yml | 1 + .../com/cloud/dc/ClusterDetailsDaoImpl.java | 5 +- .../dc/dao/DataCenterDetailsDaoImpl.java | 4 +- .../domain/dao/DomainDetailsDaoImpl.java | 14 +- .../dao/StoragePoolDetailsDaoImpl.java | 11 +- .../com/cloud/user/AccountDetailsDaoImpl.java | 11 +- .../db/ImageStoreDetailsDaoImpl.java | 15 +- .../framework/config/ConfigDepot.java | 2 + .../framework/config/ConfigKey.java | 31 ++- .../framework/config/ScopedConfigStorage.java | 6 +- .../config/impl/ConfigDepotImpl.java | 51 +++++ .../config/impl/ConfigDepotImplTest.java | 50 +++++ .../kvm/storage/KVMStorageProcessor.java | 3 +- pom.xml | 6 + .../java/com/cloud/acl/DomainChecker.java | 70 +++++-- .../ConfigurationManagerImpl.java | 13 +- .../com/cloud/user/AccountManagerImpl.java | 18 +- .../java/com/cloud/acl/DomainCheckerTest.java | 166 +++++++++++++++ .../cloud/user/AccountManagerImplTest.java | 58 +++++ .../com/cloud/vm/FirstFitPlannerTest.java | 12 +- test/integration/smoke/test_account_access.py | 198 ++++++++++++++++++ .../smoke/test_purge_expunged_vms.py | 13 +- ui/public/locales/en.json | 1 + ui/src/components/view/ListView.vue | 11 +- ui/src/components/view/SettingsTab.vue | 10 +- ui/src/views/setting/ConfigurationValue.vue | 12 +- 26 files changed, 698 insertions(+), 94 deletions(-) create mode 100644 server/src/test/java/com/cloud/acl/DomainCheckerTest.java create mode 100644 test/integration/smoke/test_account_access.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9279776a549..4da36b0b747 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/engine/schema/src/main/java/com/cloud/dc/ClusterDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/dc/ClusterDetailsDaoImpl.java index c2058ad5644..0e40f8475c1 100644 --- a/engine/schema/src/main/java/com/cloud/dc/ClusterDetailsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/dc/ClusterDetailsDaoImpl.java @@ -20,7 +20,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; - import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.ConfigKey.Scope; import org.apache.cloudstack.framework.config.ScopedConfigStorage; @@ -136,8 +135,8 @@ public class ClusterDetailsDaoImpl extends GenericDaoBase key) { - ClusterDetailsVO vo = findDetail(id, key.key()); + public String getConfigValue(long id, String key) { + ClusterDetailsVO vo = findDetail(id, key); return vo == null ? null : vo.getValue(); } diff --git a/engine/schema/src/main/java/com/cloud/dc/dao/DataCenterDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/dc/dao/DataCenterDetailsDaoImpl.java index e36c8ebd6c7..bb03a96d02e 100644 --- a/engine/schema/src/main/java/com/cloud/dc/dao/DataCenterDetailsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/dc/dao/DataCenterDetailsDaoImpl.java @@ -44,8 +44,8 @@ public class DataCenterDetailsDaoImpl extends ResourceDetailsDaoBase key) { - ResourceDetail vo = findDetail(id, key.key()); + public String getConfigValue(long id, String key) { + ResourceDetail vo = findDetail(id, key); return vo == null ? null : vo.getValue(); } diff --git a/engine/schema/src/main/java/com/cloud/domain/dao/DomainDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/domain/dao/DomainDetailsDaoImpl.java index dad3fe9ad1e..6bc08e766af 100644 --- a/engine/schema/src/main/java/com/cloud/domain/dao/DomainDetailsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/domain/dao/DomainDetailsDaoImpl.java @@ -22,6 +22,10 @@ import java.util.Map; import javax.inject.Inject; +import org.apache.cloudstack.framework.config.ConfigKey.Scope; +import org.apache.cloudstack.framework.config.ScopedConfigStorage; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; + import com.cloud.domain.DomainDetailVO; import com.cloud.domain.DomainVO; import com.cloud.utils.db.GenericDaoBase; @@ -30,10 +34,6 @@ import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; import com.cloud.utils.db.TransactionLegacy; -import org.apache.cloudstack.framework.config.ConfigKey; -import org.apache.cloudstack.framework.config.ConfigKey.Scope; -import org.apache.cloudstack.framework.config.ScopedConfigStorage; -import org.apache.cloudstack.framework.config.dao.ConfigurationDao; public class DomainDetailsDaoImpl extends GenericDaoBase implements DomainDetailsDao, ScopedConfigStorage { protected final SearchBuilder domainSearch; @@ -106,17 +106,17 @@ public class DomainDetailsDaoImpl extends GenericDaoBase i } @Override - public String getConfigValue(long id, ConfigKey key) { + public String getConfigValue(long id, String key) { DomainDetailVO vo = null; String enableDomainSettingsForChildDomain = _configDao.getValue("enable.domain.settings.for.child.domain"); if (!Boolean.parseBoolean(enableDomainSettingsForChildDomain)) { - vo = findDetail(id, key.key()); + vo = findDetail(id, key); return vo == null ? null : vo.getValue(); } DomainVO domain = _domainDao.findById(id); // if value is not configured in domain then check its parent domain till ROOT while (domain != null) { - vo = findDetail(domain.getId(), key.key()); + vo = findDetail(domain.getId(), key); if (vo != null) { break; } else if (domain.getParent() != null) { diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/StoragePoolDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/StoragePoolDetailsDaoImpl.java index 0c39a8c581a..376933f92e7 100644 --- a/engine/schema/src/main/java/com/cloud/storage/dao/StoragePoolDetailsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/StoragePoolDetailsDaoImpl.java @@ -17,6 +17,10 @@ package com.cloud.storage.dao; +import java.util.List; + +import javax.inject.Inject; + import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.ConfigKey.Scope; import org.apache.cloudstack.framework.config.ScopedConfigStorage; @@ -26,9 +30,6 @@ import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailVO; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; -import javax.inject.Inject; -import java.util.List; - public class StoragePoolDetailsDaoImpl extends ResourceDetailsDaoBase implements StoragePoolDetailsDao, ScopedConfigStorage { @Inject @@ -43,8 +44,8 @@ public class StoragePoolDetailsDaoImpl extends ResourceDetailsDaoBase key) { - StoragePoolDetailVO vo = findDetail(id, key.key()); + public String getConfigValue(long id, String key) { + StoragePoolDetailVO vo = findDetail(id, key); return vo == null ? null : vo.getValue(); } diff --git a/engine/schema/src/main/java/com/cloud/user/AccountDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/user/AccountDetailsDaoImpl.java index 5451192fc6d..4ce04892cf4 100644 --- a/engine/schema/src/main/java/com/cloud/user/AccountDetailsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/user/AccountDetailsDaoImpl.java @@ -26,20 +26,19 @@ import javax.inject.Inject; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.ConfigKey.Scope; import org.apache.cloudstack.framework.config.ScopedConfigStorage; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import com.cloud.domain.DomainDetailVO; import com.cloud.domain.DomainVO; -import com.cloud.domain.dao.DomainDetailsDao; import com.cloud.domain.dao.DomainDao; +import com.cloud.domain.dao.DomainDetailsDao; import com.cloud.user.dao.AccountDao; - import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.QueryBuilder; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; import com.cloud.utils.db.TransactionLegacy; -import org.apache.cloudstack.framework.config.dao.ConfigurationDao; public class AccountDetailsDaoImpl extends GenericDaoBase implements AccountDetailsDao, ScopedConfigStorage { protected final SearchBuilder accountSearch; @@ -116,9 +115,9 @@ public class AccountDetailsDaoImpl extends GenericDaoBase } @Override - public String getConfigValue(long id, ConfigKey key) { + public String getConfigValue(long id, String key) { // check if account level setting is configured - AccountDetailVO vo = findDetail(id, key.key()); + AccountDetailVO vo = findDetail(id, key); String value = vo == null ? null : vo.getValue(); if (value != null) { return value; @@ -138,7 +137,7 @@ public class AccountDetailsDaoImpl extends GenericDaoBase if (account.isPresent()) { DomainVO domain = _domainDao.findById(account.get().getDomainId()); while (domain != null) { - DomainDetailVO domainVO = _domainDetailsDao.findDetail(domain.getId(), key.key()); + DomainDetailVO domainVO = _domainDetailsDao.findDetail(domain.getId(), key); if (domainVO != null) { value = domainVO.getValue(); break; diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/ImageStoreDetailsDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/ImageStoreDetailsDaoImpl.java index 8e5ce770f45..14830490600 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/ImageStoreDetailsDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/ImageStoreDetailsDaoImpl.java @@ -20,6 +20,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.ConfigKey.Scope; +import org.apache.cloudstack.framework.config.ScopedConfigStorage; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; import org.springframework.stereotype.Component; import com.cloud.utils.crypt.DBEncryptionUtil; @@ -29,12 +34,6 @@ import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; import com.cloud.utils.db.TransactionLegacy; -import org.apache.cloudstack.api.ApiConstants; -import org.apache.cloudstack.framework.config.ConfigKey; -import org.apache.cloudstack.framework.config.ConfigKey.Scope; -import org.apache.cloudstack.framework.config.ScopedConfigStorage; -import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; - @Component public class ImageStoreDetailsDaoImpl extends ResourceDetailsDaoBase implements ImageStoreDetailsDao, ScopedConfigStorage { @@ -106,8 +105,8 @@ public class ImageStoreDetailsDaoImpl extends ResourceDetailsDaoBase key) { - ImageStoreDetailVO vo = findDetail(id, key.key()); + public String getConfigValue(long id, String key) { + ImageStoreDetailVO vo = findDetail(id, key); return vo == null ? null : vo.getValue(); } diff --git a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigDepot.java b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigDepot.java index b38b30e88b8..5ee5f9dec48 100644 --- a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigDepot.java +++ b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigDepot.java @@ -32,4 +32,6 @@ public interface ConfigDepot { void createOrUpdateConfigObject(String componentName, ConfigKey key, String value); boolean isNewConfig(ConfigKey configKey); + String getConfigStringValue(String key, ConfigKey.Scope scope, Long scopeId); + void invalidateConfigCache(String key, ConfigKey.Scope scope, Long scopeId); } diff --git a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java index db40c225b61..0436c9c9023 100644 --- a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java +++ b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java @@ -19,7 +19,6 @@ package org.apache.cloudstack.framework.config; import java.sql.Date; import org.apache.cloudstack.framework.config.impl.ConfigDepotImpl; -import org.apache.cloudstack.framework.config.impl.ConfigurationVO; import com.cloud.utils.Pair; import com.cloud.utils.Ternary; @@ -211,42 +210,38 @@ public class ConfigKey { public T value() { if (_value == null || isDynamic()) { - ConfigurationVO vo = (s_depot != null && s_depot.global() != null) ? s_depot.global().findById(key()) : null; - final String value = (vo != null && vo.getValue() != null) ? vo.getValue() : defaultValue(); - _value = ((value == null) ? (T)defaultValue() : valueOf(value)); + String value = s_depot != null ? s_depot.getConfigStringValue(_name, Scope.Global, null) : null; + _value = valueOf((value == null) ? defaultValue() : value); } return _value; } - public T valueIn(Long id) { + protected T valueInScope(Scope scope, Long id) { if (id == null) { return value(); } - String value = s_depot != null ? s_depot.findScopedConfigStorage(this).getConfigValue(id, this) : null; + String value = s_depot != null ? s_depot.getConfigStringValue(_name, scope, id) : null; if (value == null) { return value(); - } else { - return valueOf(value); } + return valueOf(value); + } + + public T valueIn(Long id) { + return valueInScope(_scope, id); } public T valueInDomain(Long domainId) { - if (domainId == null) { - return value(); - } - - String value = s_depot != null ? s_depot.getDomainScope(this).getConfigValue(domainId, this) : null; - if (value == null) { - return value(); - } else { - return valueOf(value); - } + return valueInScope(Scope.Domain, domainId); } @SuppressWarnings("unchecked") protected T valueOf(String value) { + if (value == null) { + return null; + } Number multiplier = 1; if (multiplier() != null) { multiplier = (Number)multiplier(); diff --git a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ScopedConfigStorage.java b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ScopedConfigStorage.java index f990278b45c..8126b9510a2 100644 --- a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ScopedConfigStorage.java +++ b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ScopedConfigStorage.java @@ -26,5 +26,9 @@ import org.apache.cloudstack.framework.config.ConfigKey.Scope; public interface ScopedConfigStorage { Scope getScope(); - String getConfigValue(long id, ConfigKey key); + String getConfigValue(long id, String key); + + default String getConfigValue(long id, ConfigKey key) { + return getConfigValue(id, key.key()); + } } diff --git a/framework/config/src/main/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImpl.java b/framework/config/src/main/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImpl.java index 46a1de950dd..28ab9f0a102 100644 --- a/framework/config/src/main/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImpl.java +++ b/framework/config/src/main/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImpl.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; import javax.annotation.PostConstruct; import javax.inject.Inject; @@ -42,6 +43,8 @@ import org.apache.log4j.Logger; import com.cloud.utils.Pair; import com.cloud.utils.Ternary; import com.cloud.utils.exception.CloudRuntimeException; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; /** * ConfigDepotImpl implements the ConfigDepot and ConfigDepotAdmin interface. @@ -72,6 +75,7 @@ import com.cloud.utils.exception.CloudRuntimeException; */ public class ConfigDepotImpl implements ConfigDepot, ConfigDepotAdmin { private final static Logger s_logger = Logger.getLogger(ConfigDepotImpl.class); + protected final static long CONFIG_CACHE_EXPIRE_SECONDS = 30; @Inject ConfigurationDao _configDao; @Inject @@ -82,12 +86,17 @@ public class ConfigDepotImpl implements ConfigDepot, ConfigDepotAdmin { List _scopedStorages; Set _configured = Collections.synchronizedSet(new HashSet()); Set newConfigs = Collections.synchronizedSet(new HashSet<>()); + Cache configCache; private HashMap>> _allKeys = new HashMap>>(1007); HashMap>> _scopeLevelConfigsMap = new HashMap>>(); public ConfigDepotImpl() { + configCache = Caffeine.newBuilder() + .maximumSize(512) + .expireAfterWrite(CONFIG_CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS) + .build(); ConfigKey.init(this); createEmptyScopeLevelMappings(); } @@ -267,6 +276,48 @@ public class ConfigDepotImpl implements ConfigDepot, ConfigDepotAdmin { return _configDao; } + protected String getConfigStringValueInternal(String cacheKey) { + String[] parts = cacheKey.split("-"); + String key = parts[0]; + ConfigKey.Scope scope = ConfigKey.Scope.Global; + Long scopeId = null; + try { + scope = ConfigKey.Scope.valueOf(parts[1]); + scopeId = Long.valueOf(parts[2]); + } catch (IllegalArgumentException ignored) {} + if (!ConfigKey.Scope.Global.equals(scope) && scopeId != null) { + ScopedConfigStorage scopedConfigStorage = null; + for (ScopedConfigStorage storage : _scopedStorages) { + if (storage.getScope() == scope) { + scopedConfigStorage = storage; + } + } + if (scopedConfigStorage == null) { + throw new CloudRuntimeException("Unable to find config storage for this scope: " + scope + " for " + key); + } + return scopedConfigStorage.getConfigValue(scopeId, key); + } + ConfigurationVO configurationVO = _configDao.findById(key); + if (configurationVO != null) { + return configurationVO.getValue(); + } + return null; + } + + private String getConfigCacheKey(String key, ConfigKey.Scope scope, Long scopeId) { + return String.format("%s-%s-%d", key, scope, (scopeId == null ? 0 : scopeId)); + } + + @Override + public String getConfigStringValue(String key, ConfigKey.Scope scope, Long scopeId) { + return configCache.get(getConfigCacheKey(key, scope, scopeId), this::getConfigStringValueInternal); + } + + @Override + public void invalidateConfigCache(String key, ConfigKey.Scope scope, Long scopeId) { + configCache.invalidate(getConfigCacheKey(key, scope, scopeId)); + } + public ScopedConfigStorage findScopedConfigStorage(ConfigKey config) { for (ScopedConfigStorage storage : _scopedStorages) { if (storage.getScope() == config.scope()) { diff --git a/framework/config/src/test/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImplTest.java b/framework/config/src/test/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImplTest.java index 8dd6f71af3c..8a7da795345 100644 --- a/framework/config/src/test/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImplTest.java +++ b/framework/config/src/test/java/org/apache/cloudstack/framework/config/impl/ConfigDepotImplTest.java @@ -23,12 +23,23 @@ import java.util.HashSet; import java.util.Set; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.junit.Assert; import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; import org.springframework.test.util.ReflectionTestUtils; +@RunWith(MockitoJUnitRunner.class) public class ConfigDepotImplTest { + @Mock + ConfigurationDao _configDao; + + @InjectMocks private ConfigDepotImpl configDepotImpl = new ConfigDepotImpl(); @Test @@ -57,4 +68,43 @@ public class ConfigDepotImplTest { Assert.assertFalse(configDepotImpl.isNewConfig(invalidNewConfig)); } + private void runTestGetConfigStringValue(String key, String value) { + ConfigurationVO configurationVO = Mockito.mock(ConfigurationVO.class); + Mockito.when(configurationVO.getValue()).thenReturn(value); + Mockito.when(_configDao.findById(key)).thenReturn(configurationVO); + String result = configDepotImpl.getConfigStringValue(key, ConfigKey.Scope.Global, null); + Assert.assertEquals(value, result); + } + + @Test + public void testGetConfigStringValue() { + runTestGetConfigStringValue("test", "value"); + } + + private void runTestGetConfigStringValueExpiry(long wait, int configDBRetrieval) { + String key = "test1"; + String value = "expiry"; + runTestGetConfigStringValue(key, value); + try { + Thread.sleep(wait); + } catch (InterruptedException ie) { + Assert.fail(ie.getMessage()); + } + String result = configDepotImpl.getConfigStringValue(key, ConfigKey.Scope.Global, null); + Assert.assertEquals(value, result); + Mockito.verify(_configDao, Mockito.times(configDBRetrieval)).findById(key); + + } + + @Test + public void testGetConfigStringValueWithinExpiry() { + runTestGetConfigStringValueExpiry((ConfigDepotImpl.CONFIG_CACHE_EXPIRE_SECONDS * 1000 ) / 4, + 1); + } + + @Test + public void testGetConfigStringValueAfterExpiry() { + runTestGetConfigStringValueExpiry(((ConfigDepotImpl.CONFIG_CACHE_EXPIRE_SECONDS) + 5) * 1000, + 2); + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java index 50c6c0c9b0c..ff55235d245 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java @@ -2447,7 +2447,8 @@ public class KVMStorageProcessor implements StorageProcessor { destPool = storagePoolMgr.getStoragePool(destPrimaryStore.getPoolType(), destPrimaryStore.getUuid()); try { - if (srcVol.getPassphrase() != null && srcVol.getVolumeType().equals(Volume.Type.ROOT)) { + Volume.Type volumeType = srcVol.getVolumeType(); + if (srcVol.getPassphrase() != null && (Volume.Type.ROOT.equals(volumeType) || Volume.Type.DATADISK.equals(volumeType))) { volume.setQemuEncryptFormat(QemuObject.EncryptFormat.LUKS); storagePoolMgr.copyPhysicalDisk(volume, destVolumeName, destPool, cmd.getWaitInMillSeconds(), srcVol.getPassphrase(), destVol.getPassphrase(), srcVol.getProvisioningType()); } else { diff --git a/pom.xml b/pom.xml index 3242ffbae26..1be1947330b 100644 --- a/pom.xml +++ b/pom.xml @@ -184,6 +184,7 @@ 5.3.26 0.5.4 1.12.0 + 3.1.7 @@ -763,6 +764,11 @@ javax.inject 1 + + com.github.ben-manes.caffeine + caffeine + ${cs.caffeine.version} + diff --git a/server/src/main/java/com/cloud/acl/DomainChecker.java b/server/src/main/java/com/cloud/acl/DomainChecker.java index a8c9ab84f7e..7930be49ec1 100644 --- a/server/src/main/java/com/cloud/acl/DomainChecker.java +++ b/server/src/main/java/com/cloud/acl/DomainChecker.java @@ -208,7 +208,7 @@ public class DomainChecker extends AdapterBase implements SecurityChecker { 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) { @@ -216,30 +216,58 @@ public class DomainChecker extends AdapterBase implements SecurityChecker { } 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; } - private boolean checkOperationPermitted(Account caller, ControlledEntity entity) { + 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; + } + + s_logger.error(String.format("Caller ID: %d does not have permission to access %s", caller.getId(), entityLog)); + throw exception; + } + + protected boolean checkOperationPermitted(Account caller, ControlledEntity entity) { User user = CallContext.current().getCallingUser(); Project project = projectDao.findByProjectAccountId(entity.getAccountId()); if (project == null) { diff --git a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java index 45632713af6..04516d4304a 100644 --- a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java +++ b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java @@ -45,8 +45,6 @@ import java.util.stream.Collectors; import javax.inject.Inject; import javax.naming.ConfigurationException; - -import com.cloud.hypervisor.HypervisorGuru; import org.apache.cloudstack.acl.SecurityChecker; import org.apache.cloudstack.affinity.AffinityGroup; import org.apache.cloudstack.affinity.AffinityGroupService; @@ -195,6 +193,7 @@ import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; import com.cloud.host.dao.HostTagsDao; import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.hypervisor.HypervisorGuru; import com.cloud.hypervisor.kvm.dpdk.DpdkHelper; import com.cloud.network.IpAddress; import com.cloud.network.IpAddressManager; @@ -677,7 +676,8 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati // if scope is mentioned as global or not mentioned then it is normal // global parameter updation if (scope != null && !scope.isEmpty() && !ConfigKey.Scope.Global.toString().equalsIgnoreCase(scope)) { - switch (ConfigKey.Scope.valueOf(scope)) { + ConfigKey.Scope scopeVal = ConfigKey.Scope.valueOf(scope); + switch (scopeVal) { case Zone: final DataCenterVO zone = _zoneDao.findById(resourceId); if (zone == null) { @@ -767,6 +767,7 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati default: throw new InvalidParameterValueException("Scope provided is invalid"); } + _configDepot.invalidateConfigCache(name, scopeVal, resourceId); return value; } @@ -779,6 +780,7 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati s_logger.error("Failed to update configuration option, name: " + name + ", value:" + value); throw new CloudRuntimeException("Failed to update configuration value. Please contact Cloud Support."); } + _configDepot.invalidateConfigCache(name, ConfigKey.Scope.Global, null); PreparedStatement pstmt = null; if (Config.XenServerGuestNetwork.key().equalsIgnoreCase(name)) { @@ -1054,7 +1056,8 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati } String newValue = null; - switch (ConfigKey.Scope.valueOf(scope)) { + ConfigKey.Scope scopeVal = ConfigKey.Scope.valueOf(scope); + switch (scopeVal) { case Zone: final DataCenterVO zone = _zoneDao.findById(id); if (zone == null) { @@ -1139,6 +1142,8 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati newValue = optionalValue.isPresent() ? optionalValue.get().toString() : defaultValue; } + _configDepot.invalidateConfigCache(name, scopeVal, id); + CallContext.current().setEventDetails(" Name: " + name + " New Value: " + (name.toLowerCase().contains("password") ? "*****" : defaultValue == null ? "" : defaultValue)); return new Pair(_configDao.findByName(name), newValue); } diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index 3684657faec..5c7aec5a1b7 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -2717,7 +2717,9 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M 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 keys = new HashMap(); keys.put("apikey", user.getApiKey()); @@ -2726,6 +2728,19 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M 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 listUserTwoFactorAuthenticationProviders() { return userTwoFactorAuthenticationProviders; @@ -2760,6 +2775,7 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M } Account account = _accountDao.findById(user.getAccountId()); + preventRootDomainAdminAccessToRootAdminKeys(user, account); checkAccess(caller, null, true, account); // don't allow updating system user diff --git a/server/src/test/java/com/cloud/acl/DomainCheckerTest.java b/server/src/test/java/com/cloud/acl/DomainCheckerTest.java new file mode 100644 index 00000000000..a5ec41306d8 --- /dev/null +++ b/server/src/test/java/com/cloud/acl/DomainCheckerTest.java @@ -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 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 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 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 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 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); + } + +} diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java index 2f3a68e20af..262fdeced3e 100644 --- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java +++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java @@ -24,6 +24,7 @@ import java.util.List; 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; @@ -240,6 +241,63 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase { 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); diff --git a/server/src/test/java/com/cloud/vm/FirstFitPlannerTest.java b/server/src/test/java/com/cloud/vm/FirstFitPlannerTest.java index cdee3999886..2987d63b49a 100644 --- a/server/src/test/java/com/cloud/vm/FirstFitPlannerTest.java +++ b/server/src/test/java/com/cloud/vm/FirstFitPlannerTest.java @@ -30,7 +30,6 @@ import java.util.Map; import javax.inject.Inject; -import com.cloud.offering.ServiceOffering; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; import org.apache.cloudstack.framework.config.ConfigDepot; @@ -40,7 +39,6 @@ import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.config.dao.ConfigurationGroupDao; import org.apache.cloudstack.framework.config.dao.ConfigurationSubGroupDao; import org.apache.cloudstack.framework.config.impl.ConfigDepotImpl; -import org.apache.cloudstack.framework.config.impl.ConfigurationVO; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.test.utils.SpringUtils; import org.junit.After; @@ -78,7 +76,9 @@ import com.cloud.exception.InsufficientServerCapacityException; import com.cloud.gpu.dao.HostGpuGroupsDao; import com.cloud.host.Host; import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostDetailsDao; import com.cloud.host.dao.HostTagsDao; +import com.cloud.offering.ServiceOffering; import com.cloud.resource.ResourceManager; import com.cloud.service.ServiceOfferingVO; import com.cloud.service.dao.ServiceOfferingDao; @@ -97,7 +97,6 @@ import com.cloud.utils.component.ComponentContext; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.UserVmDetailsDao; import com.cloud.vm.dao.VMInstanceDao; -import com.cloud.host.dao.HostDetailsDao; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(loader = AnnotationConfigContextLoader.class) @@ -244,11 +243,8 @@ public class FirstFitPlannerTest { } private List initializeForClusterThresholdDisabled() { - when(configDepot.global()).thenReturn(configDao); - - ConfigurationVO config = mock(ConfigurationVO.class); - when(config.getValue()).thenReturn(String.valueOf(false)); - when(configDao.findById(DeploymentClusterPlanner.ClusterThresholdEnabled.key())).thenReturn(config); + when(configDepot.getConfigStringValue(DeploymentClusterPlanner.ClusterThresholdEnabled.key(), + ConfigKey.Scope.Global, null)).thenReturn(Boolean.FALSE.toString()); List clustersCrossingThreshold = new ArrayList(); clustersCrossingThreshold.add(3L); diff --git a/test/integration/smoke/test_account_access.py b/test/integration/smoke/test_account_access.py new file mode 100644 index 00000000000..97eeced6386 --- /dev/null +++ b/test/integration/smoke/test_account_access.py @@ -0,0 +1,198 @@ +# 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. +""" BVT tests for Account User Access +""" +# Import Local Modules +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.lib.utils import * +from marvin.lib.base import (Account, + User, + Domain) +from marvin.lib.common import (get_domain) +from marvin.cloudstackAPI import (getUserKeys) +from marvin.cloudstackException import CloudstackAPIException +from nose.plugins.attrib import attr + +_multiprocess_shared_ = True + +class TestAccountAccess(cloudstackTestCase): + + @classmethod + def setUpClass(cls): + testClient = super(TestAccountAccess, cls).getClsTestClient() + cls.apiclient = testClient.getApiClient() + cls.services = testClient.getParsedTestDataConfig() + cls.hypervisor = testClient.getHypervisorInfo() + cls._cleanup = [] + + # Get Zone, Domain and templates + cls.domain = get_domain(cls.apiclient) + + cls.domains = [] + cls.domain_admins = {} + cls.domain_users = {} + cls.account_users = {} + + domain_data = { + "name": "domain_1" + } + cls.domain_1 = Domain.create( + cls.apiclient, + domain_data, + ) + cls._cleanup.append(cls.domain_1) + cls.domains.append(cls.domain_1) + domain_data["name"] = "domain_11" + cls.domain_11 = Domain.create( + cls.apiclient, + domain_data, + parentdomainid=cls.domain_1.id + ) + cls._cleanup.append(cls.domain_11) + cls.domains.append(cls.domain_11) + domain_data["name"] = "domain_12" + cls.domain_12 = Domain.create( + cls.apiclient, + domain_data, + parentdomainid=cls.domain_1.id + ) + cls._cleanup.append(cls.domain_12) + cls.domains.append(cls.domain_12) + domain_data["name"] = "domain_2" + cls.domain_2 = Domain.create( + cls.apiclient, + domain_data, + ) + cls._cleanup.append(cls.domain_2) + cls.domains.append(cls.domain_2) + + + for d in cls.domains: + cls.create_domainadmin_and_user(d) + + @classmethod + def tearDownClass(cls): + super(TestAccountAccess, cls).tearDownClass() + + @classmethod + def create_account(cls, domain, is_admin): + cls.debug(f"Creating account for domain {domain.name}, admin: {is_admin}") + data = { + "email": "admin-" + domain.name + "@test.com", + "firstname": "Admin", + "lastname": domain.name, + "username": "admin-" + domain.name, + "password": "password" + } + if is_admin == False: + data["email"] = "user-" + domain.name + "@test.com" + data["firstname"] = "User" + data["username"] = "user-" + domain.name + account = Account.create( + cls.apiclient, + data, + admin=is_admin, + domainid=domain.id + ) + cls._cleanup.append(account) + if is_admin == True: + cls.domain_admins[domain.id] = account + else: + cls.domain_users[domain.id] = account + + user = User.create( + cls.apiclient, + data, + account=account.name, + domainid=account.domainid) + cls._cleanup.append(user) + cls.account_users[account.id] = user + + @classmethod + def create_domainadmin_and_user(cls, domain): + cls.debug(f"Creating accounts for domain #{domain.id} {domain.name}") + cls.create_account(domain, True) + cls.create_account(domain, False) + + def get_user_keys(self, api_client, user_id): + getUserKeysCmd = getUserKeys.getUserKeysCmd() + getUserKeysCmd.id = user_id + return api_client.getUserKeys(getUserKeysCmd) + + def is_child_domain(self, parent_domain, child_domain): + if not parent_domain or not child_domain: + return False + parent_domain_prefix = parent_domain.split('-')[0] + child_domain_prefix = child_domain.split('-')[0] + if not parent_domain_prefix or not child_domain_prefix: + return False + return child_domain_prefix.startswith(parent_domain_prefix) + + + @attr(tags=["advanced", "advancedns", "smoke", "sg"], required_hardware="false") + def test_01_user_access(self): + """ + Test user account is not accessing any other account + """ + + domain_user_accounts = [value for value in self.domain_users.values()] + all_account_users = [value for value in self.account_users.values()] + for user_account in domain_user_accounts: + current_account_user = self.account_users[user_account.id] + self.debug(f"Check for account {user_account.name} with user {current_account_user.username}") + user_api_client = self.testClient.getUserApiClient( + UserName=user_account.name, + DomainName=user_account.domain + ) + for user in all_account_users: + self.debug(f"Checking access for user {user.username} associated with account {user.account}") + try: + self.get_user_keys(user_api_client, user.id) + self.debug(f"API successful") + if user.id != current_account_user.id: + self.fail(f"User account #{user_account.id} was able to access another account #{user.id}") + except CloudstackAPIException as e: + self.debug(f"Exception occurred: {e}") + if user.id == current_account_user.id: + self.fail(f"User account #{user_account.id} not able to access own account") + + @attr(tags=["advanced", "advancedns", "smoke", "sg"], required_hardware="false") + def test_02_domain_admin_access(self): + """ + Test domain admin account is not accessing any other account from unauthorized domain + """ + + domain_admin_accounts = [value for value in self.domain_admins.values()] + all_account_users = [value for value in self.account_users.values()] + for admin_account in domain_admin_accounts: + current_account_user = self.account_users[admin_account.id] + self.debug(f"Check for domain admin {admin_account.name} with user {current_account_user.username}, {current_account_user.domain}") + admin_api_client = self.testClient.getUserApiClient( + UserName=admin_account.name, + DomainName=admin_account.domain + ) + for user in all_account_users: + self.debug(f"Checking access for user {user.username}, {user.domain} associated with account {user.account}") + try: + self.get_user_keys(admin_api_client, user.id) + self.debug(f"API successful") + if self.is_child_domain(current_account_user.domain, user.domain) == False: + self.fail(f"User account #{admin_account.id} was able to access another account #{user.id}") + except CloudstackAPIException as e: + self.debug(f"Exception occurred: {e}") + if self.is_child_domain(current_account_user.domain, user.domain) == True: + self.fail(f"User account #{admin_account.id} not able to access own account") diff --git a/test/integration/smoke/test_purge_expunged_vms.py b/test/integration/smoke/test_purge_expunged_vms.py index 4d885dc68e1..0fe55991059 100644 --- a/test/integration/smoke/test_purge_expunged_vms.py +++ b/test/integration/smoke/test_purge_expunged_vms.py @@ -273,6 +273,7 @@ class TestPurgeExpungedVms(cloudstackTestCase): return False self.debug("Restarting all management server") for idx, server_ip in enumerate(server_ips): + self.debug(f"Restarting management server #{idx} with IP {server_ip}") sshClient = SshClient( server_ip, 22, @@ -283,6 +284,9 @@ class TestPurgeExpungedVms(cloudstackTestCase): sshClient.execute(command) command = "service cloudstack-management start" sshClient.execute(command) + if idx == 0: + # Wait before restarting other management servers to make the first as oldest running + time.sleep(10) # Waits for management to come up in 10 mins, when it's up it will continue timeout = time.time() + (10 * 60) @@ -349,15 +353,18 @@ class TestPurgeExpungedVms(cloudstackTestCase): @skipTestIf("hypervisorIsSimulator") @attr(tags=["advanced"], required_hardware="true") def test_06_purge_expunged_vm_background_task(self): - purge_task_delay = 60 + purge_task_delay = 120 self.changeConfiguration('expunged.resources.purge.enabled', 'true') self.changeConfiguration('expunged.resources.purge.delay', purge_task_delay) + self.changeConfiguration('expunged.resources.purge.interval', int(purge_task_delay/2)) self.changeConfiguration('expunged.resources.purge.keep.past.days', 1) if len(self.staticConfigurations) > 0: self.restartAllManagementServers() - wait = 2 * purge_task_delay - logging.info("Waiting for 2x%d = %d seconds for background task to execute" % (purge_task_delay, wait)) + wait_multiple = 2 + wait = wait_multiple * purge_task_delay + logging.info(f"Waiting for {wait_multiple}x{purge_task_delay} = {wait} seconds for background task to execute") time.sleep(wait) + logging.debug("Validating expunged VMs") self.validatePurgedVmEntriesInDb( [self.vm_ids[self.timestamps[0]], self.vm_ids[self.timestamps[1]], self.vm_ids[self.timestamps[2]]], None diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 755425118ea..b94c4848c4f 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -2887,6 +2887,7 @@ "message.set.default.nic": "Please confirm that you would like to make this NIC the default for this VM.", "message.set.default.nic.manual": "Please manually update the default NIC on the VM now.", "message.setting.updated": "Setting Updated:", +"message.setting.update.delay": "The new value will take effect within 30 seconds.", "message.setup.physical.network.during.zone.creation": "When adding a zone, you need to set up one or more physical networks. Each network corresponds to a NIC on the hypervisor. Each physical network can carry one or more types of traffic, with certain restrictions on how they may be combined. Add or remove one or more traffic types onto each physical network.", "message.setup.physical.network.during.zone.creation.basic": "When adding a basic zone, you can set up one physical network, which corresponds to a NIC on the hypervisor. The network carries several types of traffic.

You may also add other traffic types onto the physical network.", "message.shared.network.offering.warning": "Domain admins and regular users can only create shared networks from network offering with the setting specifyvlan=false. Please contact an administrator to create a network offering if this list is empty.", diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue index 286d9d16d2a..f78b4cc0db4 100644 --- a/ui/src/components/view/ListView.vue +++ b/ui/src/components/view/ListView.vue @@ -617,7 +617,11 @@ export default { }).then(json => { this.editableValueKey = null this.$store.dispatch('RefreshFeatures') - this.$message.success(`${this.$t('message.setting.updated')} ${record.name}`) + var message = `${this.$t('message.setting.updated')} ${record.name}` + if (record.isdynamic) { + message += `. ${this.$t('message.setting.update.delay')}` + } + this.$message.success(message) if (json.updateconfigurationresponse && json.updateconfigurationresponse.configuration && !json.updateconfigurationresponse.configuration.isdynamic && @@ -638,7 +642,10 @@ export default { api('resetConfiguration', { name: item.name }).then(() => { - const message = `${this.$t('label.setting')} ${item.name} ${this.$t('label.reset.config.value')}` + var message = `${this.$t('label.setting')} ${item.name} ${this.$t('label.reset.config.value')}` + if (item.isdynamic) { + message += `. ${this.$t('message.setting.update.delay')}` + } this.$message.success(message) }).catch(error => { console.error(error) diff --git a/ui/src/components/view/SettingsTab.vue b/ui/src/components/view/SettingsTab.vue index cddbe56b83a..f0812c3a8ad 100644 --- a/ui/src/components/view/SettingsTab.vue +++ b/ui/src/components/view/SettingsTab.vue @@ -174,7 +174,10 @@ export default { name: item.name, value: this.editableValue }).then(() => { - const message = `${this.$t('label.setting')} ${item.name} ${this.$t('label.update.to')} ${this.editableValue}` + var message = `${this.$t('label.setting')} ${item.name} ${this.$t('label.update.to')} ${this.editableValue}` + if (item.isdynamic) { + message += `. ${this.$t('message.setting.update.delay')}` + } this.handleSuccessMessage(item.name, this.$route.meta.name, message) }).catch(error => { console.error(error) @@ -204,7 +207,10 @@ export default { [this.scopeKey]: this.resource.id, name: item.name }).then(() => { - const message = `${this.$t('label.setting')} ${item.name} ${this.$t('label.reset.config.value')}` + var message = `${this.$t('label.setting')} ${item.name} ${this.$t('label.reset.config.value')}` + if (item.isdynamic) { + message += `. ${this.$t('message.setting.update.delay')}` + } this.handleSuccessMessage(item.name, this.$route.meta.name, message) }).catch(error => { console.error(error) diff --git a/ui/src/views/setting/ConfigurationValue.vue b/ui/src/views/setting/ConfigurationValue.vue index 0069896f7a5..a4683a37567 100644 --- a/ui/src/views/setting/ConfigurationValue.vue +++ b/ui/src/views/setting/ConfigurationValue.vue @@ -250,7 +250,11 @@ export default { this.actualValue = this.editableValue this.$emit('change-config', { value: newValue }) this.$store.dispatch('RefreshFeatures') - this.$message.success(`${this.$t('message.setting.updated')} ${configrecord.name}`) + var message = `${this.$t('message.setting.updated')} ${configrecord.name}` + if (configrecord.isdynamic) { + message += `. ${this.$t('message.setting.update.delay')}` + } + this.$message.success(message) if (json.updateconfigurationresponse && json.updateconfigurationresponse.configuration && !json.updateconfigurationresponse.configuration.isdynamic && @@ -287,7 +291,11 @@ export default { } this.$emit('change-config', { value: newValue }) this.$store.dispatch('RefreshFeatures') - this.$message.success(`${this.$t('label.setting')} ${configrecord.name} ${this.$t('label.reset.config.value')}`) + var message = `${this.$t('label.setting')} ${configrecord.name} ${this.$t('label.reset.config.value')}` + if (configrecord.isdynamic) { + message += `. ${this.$t('message.setting.update.delay')}` + } + this.$message.success(message) if (json.resetconfigurationresponse && json.resetconfigurationresponse.configuration && !json.resetconfigurationresponse.configuration.isdynamic &&